├── .gitignore ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── aternos.har ├── aternos_ws.txt ├── check.sh ├── docs ├── howto │ ├── auth.md │ ├── config.md │ ├── discord.md │ ├── files.md │ ├── players.md │ ├── server.md │ └── websocket.md ├── index.md └── reference │ ├── atclient.md │ ├── atconf.md │ ├── atconnect.md │ ├── aterrors.md │ ├── atfile.md │ ├── atfm.md │ ├── atjsparse.md │ ├── atplayers.md │ ├── atserver.md │ └── atwss.md ├── examples ├── console_example.py ├── files_example.py ├── info_example.py ├── start_example.py ├── websocket_args_example.py ├── websocket_example.py └── websocket_status_example.py ├── logo ├── aternos_400.png ├── aternos_800.png ├── square_400.png └── square_800.png ├── mkdocs.yml ├── pylintrc ├── pyproject.toml ├── python_aternos ├── __init__.py ├── ataccount.py ├── atclient.py ├── atconf.py ├── atconnect.py ├── aterrors.py ├── atfile.py ├── atfm.py ├── atjsparse.py ├── atlog.py ├── atmd5.py ├── atplayers.py ├── atserver.py ├── atwss.py └── data │ ├── package-lock.json │ ├── package.json │ └── server.js ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── files.py ├── js_samples.py ├── mock.py ├── requirements.txt ├── samples ├── html │ ├── aternos_config │ ├── aternos_files_root │ ├── aternos_go │ ├── aternos_players │ ├── aternos_server1 │ └── aternos_servers ├── token_input.txt └── token_output.txt ├── test_http.py ├── test_js2py.py ├── test_jsnode.py └── test_login.py /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # MkDocs 76 | site/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 83 | __pypackages__/ 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # mypy 95 | .mypy_cache/ 96 | .dmypy.json 97 | dmypy.json 98 | 99 | # Pyre type checker 100 | .pyre/ 101 | 102 | # pytype static type analyzer 103 | .pytype/ 104 | 105 | # Cython debug symbols 106 | cython_debug/ 107 | 108 | # IDE 109 | .vscode/ 110 | 111 | # Credentials for unittest 112 | tests/samples/login_pswd.txt 113 | 114 | # NPM 115 | node_modules/ 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | python3 -m build 3 | 4 | upload: 5 | python3 -m twine upload dist/* 6 | 7 | doc: 8 | python3 -m mkdocs build 9 | 10 | clean: 11 | rm -rf dist build python_aternos.egg-info 12 | rm -rf python_aternos/__pycache__ 13 | rm -rf examples/__pycache__ 14 | rm -rf tests/__pycache__ 15 | rm -rf site .mypy_cache 16 | 17 | test: 18 | python3 -m unittest discover -v ./tests 19 | 20 | check: 21 | python3 -m mypy ./python_aternos 22 | python3 -m pylint ./python_aternos 23 | 24 | fullcheck: 25 | chmod +x check.sh; bash check.sh 26 | 27 | format: 28 | python3 -m autopep8 -r --in-place ./python_aternos 29 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2021-2022 All contributors 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Python Aternos Logo 3 |

4 | [UNMAINTAINED] Python Aternos 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |

20 |
21 | 22 | An unofficial Aternos API written in Python. 23 | It uses [aternos](https://aternos.org/)' private API and html parsing. 24 | 25 | > [!WARNING] 26 | > 27 | > This library is no longer maintained, because: 28 | > 1. Aternos started detecting all automated requests (and, therefore, ToS violations) 29 | > via JS code in `AJAX_TOKEN` which is executed incorrectly in Js2Py and 30 | > requires a NodeJS DOM library (at least) or a browser engine. 31 | > For details, see [#85](https://github.com/DarkCat09/python-aternos/issues/85). 32 | > 2. Aternos frontend is protected with Cloudflare, so this library fails to parse pages 33 | > in case of, for example, blocked or suspicious IP address (e.g. web hosting). 34 | > CF shows IUAM page, often with captcha. We need a browser engine like undetected-chromedriver and an AI or a man solving captchas. 35 | > 3. Last Aternos API update broke nearly everything. 36 | > 4. I have no more motivation and not enough time to work on this, nor need in using Aternos. 37 | > 38 | > I'm so sorry. If you want to continue development of python-aternos, 39 | > [contact me](https://url.dc09.ru/contact), but I think it's better to write from scratch. 40 | 41 | Python Aternos supports: 42 | 43 | - Logging in to account with password (plain or hashed) or `ATERNOS_SESSION` cookie value 44 | - Saving session to the file and restoring 45 | - Changing username, email and password 46 | - Parsing Minecraft servers list 47 | - Parsing server info by its ID 48 | - Starting/stoping server, restarting, confirming/cancelling launch 49 | - Updating server info in real-time (see [WebSocket API](https://python-aternos.codeberg.page/howto/websocket)) 50 | - Changing server subdomain and MOTD (message-of-the-day) 51 | - Managing files, settings, players (whitelist, operators, etc.) 52 | 53 | ## Install 54 | 55 | ### Common 56 | ```bash 57 | $ pip install python-aternos 58 | ``` 59 | > **Note** for Windows users 60 | > 61 | > Install `lxml` package from [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#lxml) 62 | > if you have problems with it, and then execute: 63 | > `pip install --no-deps python-aternos` 64 | 65 | ### Development 66 | ```bash 67 | $ git clone https://github.com/DarkCat09/python-aternos.git 68 | $ cd python-aternos 69 | $ pip install -e .[dev] 70 | ``` 71 | 72 | ## Usage 73 | To use Aternos API in your Python script, import it 74 | and login with your username and password or its MD5 hash. 75 | 76 | Then request the servers list using `list_servers()`. 77 | You can start/stop your Aternos server, calling `start()` or `stop()`. 78 | 79 | Here is an example how to use the API: 80 | ```python 81 | # Import 82 | from python_aternos import Client 83 | 84 | # Create object 85 | atclient = Client() 86 | 87 | # Log in 88 | # with username and password 89 | atclient.login('example', 'test123') 90 | # ----OR---- 91 | # with username and MD5 hashed password 92 | atclient.login_hashed('example', 'cc03e747a6afbbcbf8be7668acfebee5') 93 | # ----OR---- 94 | # with session cookie 95 | atclient.login_with_session('ATERNOS_SESSION cookie value') 96 | 97 | # Get AternosAccount object 98 | aternos = atclient.account 99 | 100 | # Get servers list 101 | servs = aternos.list_servers() 102 | 103 | # Get the first server 104 | myserv = servs[0] 105 | 106 | # Start 107 | myserv.start() 108 | # Stop 109 | myserv.stop() 110 | 111 | # You can also find server by IP 112 | testserv = None 113 | for serv in servs: 114 | if serv.address == 'test.aternos.org': 115 | testserv = serv 116 | 117 | if testserv is not None: 118 | # Prints the server software and its version 119 | # (for example, "Vanilla 1.12.2") 120 | print(testserv.software, testserv.version) 121 | # Starts server 122 | testserv.start() 123 | ``` 124 | 125 | ## [More examples](https://github.com/DarkCat09/python-aternos/tree/main/examples) 126 | 127 | ## [Documentation](https://python-aternos.codeberg.page) 128 | 129 | ## [How-To Guide](https://python-aternos.codeberg.page/howto/auth) 130 | 131 | ## Changelog 132 | |Version|Description | 133 | |:-----:|:-----------| 134 | |v0.3|Implemented files API, added typization.| 135 | |v0.4|Implemented configuration API, some bugfixes.| 136 | |v0.5|The API was updated corresponding to new Aternos security methods. Huge thanks to [lusm554](https://github.com/lusm554).| 137 | |**v0.6/v1.0.0**|Code refactoring, websockets API and session saving to prevent detecting automation access.| 138 | |v1.0.x|Lots of bugfixes, changed versioning (SemVer).| 139 | |v1.1.x|Documentation, unit tests, pylint, bugfixes, changes in atwss.| 140 | |**v1.1.2/v2.0.0**|Solution for [#25](https://github.com/DarkCat09/python-aternos/issues/25) (Cloudflare bypassing), bugfixes in JS parser.| 141 | |v2.0.x|Documentation, automatically saving/restoring session, improvements in Files API.| 142 | |v2.1.x|Fixes in websockets API, atconnect (including cookie refreshing fix). Support for captcha solving services (view [#52](https://github.com/DarkCat09/python-aternos/issues/52)).| 143 | |v2.2.x|Node.JS interpreter support.| 144 | |v3.0.0|Partially rewritten, API updates.| 145 | |v3.0.5|Unmaintained.| 146 | |v3.1.x|TODO: Full implementation of config API.| 147 | |v3.2.x|TODO: Shared access API and maybe Google Drive backups.| 148 | 149 | ## Reversed API Specification 150 | Private Aternos API requests were captured into 151 | [this HAR file](https://github.com/DarkCat09/python-aternos/blob/main/aternos.har) 152 | and were imported to 153 | [a Postman Workspace](https://www.postman.com/darkcat09/workspace/aternos-api). 154 | You can use both resources to explore the API. 155 | Any help with improving this library is welcome. 156 | 157 | ## License 158 | [License Notice:](https://github.com/DarkCat09/python-aternos/blob/main/NOTICE) 159 | ``` 160 | Copyright 2021-2022 All contributors 161 | 162 | Licensed under the Apache License, Version 2.0 (the "License"); 163 | you may not use this file except in compliance with the License. 164 | You may obtain a copy of the License at 165 | 166 | http://www.apache.org/licenses/LICENSE-2.0 167 | 168 | Unless required by applicable law or agreed to in writing, software 169 | distributed under the License is distributed on an "AS IS" BASIS, 170 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 171 | See the License for the specific language governing permissions and 172 | limitations under the License. 173 | ``` 174 | -------------------------------------------------------------------------------- /aternos_ws.txt: -------------------------------------------------------------------------------- 1 | Client - C, Server - S. 2 | Note: "stream"/"type" means the type of received data: 3 | a new console line, an updated RAM or TPS value... 4 | 5 | C> 6 | GET wss://aternos.org/hermes/ 7 | Connection: Upgrade 8 | Cookie: ATERNOS_SESSION=***SESSION***; ATERNOS_SERVER=***SERVER_ID*** 9 | Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits 10 | Sec-WebSocket-Key: 294tmb+eQCF0CfWz5lvZ8A== 11 | Sec-WebSocket-Version: 13 12 | Upgrade: websocket 13 | User-Agent: ... 14 | S> 15 | HTTP/1.1 101 Switching Protocols 16 | Connection: Upgrade 17 | sec-websocket-accept: TdeB4uvmMuNigQtzJuOjUzqoSkc= 18 | upgrade: websocket 19 | 20 | WEBSOCKET: 21 | 22 | # Init 23 | S> {"type":"ready","data":"***SERVER_ID***"} 24 | # Start 25 | C> {"stream":"console","type":"start"} 26 | S> {"type":"connected"} 27 | S> {"stream":"console","type":"started"} 28 | # Log 29 | S> {"stream":"console","type":"line","data":"[23:27:07] [Server thread/INFO] [FML]: Injecting itemstacks\r"} 30 | C> {"stream":"console","type":"command","data":"list"} 31 | S> {"stream":"console","type":"line","data":"list\r"} 32 | S> {"stream":"console","type":"line","data":"[23:28:28] [Server thread/INFO] [minecraft/DedicatedServer]: There are §r0§r/§r8§r players online:§r\r"} 33 | S> {"stream":"console","type":"line","data":"[23:28:28] [Server thread/INFO] [minecraft/DedicatedServer]: \r"} 34 | # ??? 35 | C> {"type":"❤"} 36 | 37 | *** Page was refreshed 38 | 39 | C> 40 | GET wss://aternos.org/hermes/ 41 | Connection: Upgrade 42 | Cookie: ATERNOS_SESSION=***SESSION***; ATERNOS_SERVER=***SERVER_ID*** 43 | Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits 44 | Sec-WebSocket-Key: sT1PpNq/AhfY3HpIUplRIg== 45 | Sec-WebSocket-Version: 13 46 | Upgrade: websocket 47 | User-Agent: ... 48 | S> 49 | HTTP/1.1 101 Switching Protocols 50 | Connection: upgrade 51 | sec-websocket-accept: 9HWtmcvFcxO8HeHQU5nXO8+R19k= 52 | upgrade: websocket 53 | 54 | # Init 55 | S> {"type":"ready","data":"cDRCfaQjQDaABPKL"} 56 | # Start 57 | C> {"stream":"heap","type":"start"} 58 | S> {"type":"connected"} 59 | S> {"stream":"heap","type":"started"} 60 | # RAM usage 61 | S> {"stream":"heap","type":"heap","data":{"usage":879372712}} 62 | # Changing state (lastStatus object) 63 | S> {"type":"status","message":"{\"brand\":\"aternos\",\"status\":3,\"change\":1640978969,\"slots\":8,\"problems\":0,\"players\":0,\"playerlist\":[],\"message\":{\"text\":\"\",\"class\":\"blue\"},\"dynip\":null,\"bedrock\":false,\"host\":\"croaker.aternos.host\",\"port\":61370,\"headstarts\":null,\"ram\":0,\"lang\":\"stopping\",\"label\":\"\\u041e\\u0441\\u0442\\u0430\\u043d\\u043e\\u0432\\u043a\\u0430 ...\",\"class\":\"loading\",\"countdown\":null,\"queue\":null,\"id\":\"cDRCfaQjQDaABPKL\",\"name\":\"inc09\",\"software\":\"Forge\",\"softwareId\":\"p71sAEKNbhea4UEm\",\"type\":\"forge\",\"version\":\"1.12.2 (14.23.5.2860)\",\"deprecated\":false,\"ip\":\"inc09.aternos.me\",\"displayAddress\":\"inc09.aternos.me:61370\",\"motd\":\"IndustrialCraft 2\",\"icon\":\"fa-spinner-third fa-spin\",\"dns\":{\"type\":\"SRV\",\"domains\":[\"inc09.aternos.me\"],\"host\":\"croaker.aternos.host\",\"port\":61370,\"ip\":\"185.116.158.143\"}}"} 64 | # Stop 65 | C> {"stream":"heap","type":"stop"} 66 | S> {"stream":"heap","type":"stopped"} 67 | S> {"type":"disconnected","data":"server"} 68 | # Changing state (lastStatus object) 69 | S> {"type":"status","message":"{\"brand\":\"aternos\",\"status\":5,\"change\":1640978973,\"slots\":8,\"problems\":0,\"players\":0,\"playerlist\":[],\"message\":{\"text\":\"\",\"class\":\"blue\"},\"dynip\":null,\"bedrock\":false,\"host\":\"croaker.aternos.host\",\"port\":61370,\"headstarts\":null,\"ram\":0,\"lang\":\"saving\",\"label\":\"\\u0421\\u043e\\u0445\\u0440\\u0430\\u043d\\u0435\\u043d\\u0438\\u0435 ...\",\"class\":\"loading\",\"countdown\":null,\"queue\":null,\"id\":\"cDRCfaQjQDaABPKL\",\"name\":\"inc09\",\"software\":\"Forge\",\"softwareId\":\"p71sAEKNbhea4UEm\",\"type\":\"forge\",\"version\":\"1.12.2 (14.23.5.2860)\",\"deprecated\":false,\"ip\":\"inc09.aternos.me\",\"displayAddress\":\"inc09.aternos.me:61370\",\"motd\":\"IndustrialCraft 2\",\"icon\":\"fa-spinner-third fa-spin\",\"dns\":{\"type\":\"DEFAULT\",\"domains\":[\"inc09.aternos.me\"],\"host\":null,\"port\":null}}"} 70 | S> {"type":"status","message":"{\"brand\":\"aternos\",\"status\":0,\"change\":1640978991,\"slots\":8,\"problems\":0,\"players\":0,\"playerlist\":[],\"message\":{\"text\":\"\",\"class\":\"blue\"},\"dynip\":null,\"bedrock\":false,\"host\":\"\",\"port\":61370,\"headstarts\":null,\"ram\":0,\"lang\":\"offline\",\"label\":\"\\u041e\\u0444\\u0444\\u043b\\u0430\\u0439\\u043d\",\"class\":\"offline\",\"countdown\":null,\"queue\":null,\"id\":\"cDRCfaQjQDaABPKL\",\"name\":\"inc09\",\"software\":\"Forge\",\"softwareId\":\"p71sAEKNbhea4UEm\",\"type\":\"forge\",\"version\":\"1.12.2 (14.23.5.2860)\",\"deprecated\":false,\"ip\":\"inc09.aternos.me\",\"displayAddress\":\"inc09.aternos.me:61370\",\"motd\":\"IndustrialCraft 2\",\"icon\":\"fa-stop-circle\",\"dns\":{\"type\":\"DEFAULT\",\"domains\":[\"inc09.aternos.me\"],\"host\":null,\"port\":null}}"} 71 | # Backup (why is this info here?) 72 | S> {"type":"backup_progress","message":"{\"id\":\"\",\"progress\":0,\"action\":\"reset\",\"auto\":false,\"done\":false}"} 73 | # ??? 74 | C> {"type":"❤"} 75 | 76 | *** Queue example 77 | S> {"type":"queue_reduced","message":"{\"queue\":0,\"total\":49,\"maxtime\":3}"} 78 | S> {"type":"queue_reduced","message":"{\"queue\":0,\"total\":49,\"maxtime\":2}"} 79 | 80 | # From server.js: 81 | # var percentage = lastStatus.queue.position / queue.total * 100; 82 | if (queue.maxtime) { 83 | let minutes = Math.round((queue.maxtime - ((Date.now() / 1000) - lastStatus.queue.jointime)) / 60); 84 | if (minutes < 1 || lastStatus.queue.pending === "ready") { 85 | minutes = 1; 86 | } 87 | if (minutes > lastStatus.queue.minutes) { 88 | minutes = lastStatus.queue.minutes; 89 | } 90 | if (lastStatus.queue.minutes - minutes <= 3) { 91 | lastStatus.queue.minutes = minutes; 92 | } 93 | lastStatus.queue.time = "ca. " + lastStatus.queue.minutes + " min"; 94 | } 95 | 96 | *** type=tick 97 | S> {"stream":"tick","type":"tick","data":{"averageTickTime":8.526042}} 98 | S> {"stream":"tick","type":"tick","data":{"averageTickTime":1.4948028}} 99 | 100 | # From legilimens.js: 101 | # let tps = Math.round(Math.min(1000 / data.averageTickTime, 20) * 10) / 10; 102 | 103 | *** players 104 | S> {"type":"status","message":"{\"brand\":\"aternos\",\"status\":1,\"change\":1642165691,\"slots\":20,\"problems\":0,\"players\":1,\"playerlist\":[\"s1e2m3e4n\"],\"message\":{\"text\":\"\",\"class\":\"blue\"},\"dynip\":\"sawfish.aternos.host:46436\",\"bedrock\":false,\"host\":\"sawfish.aternos.host\",\"port\":46436,\"headstarts\":null,\"ram\":1700,\"lang\":\"online\",\"label\":\"Online\",\"class\":\"online\",\"countdown\":null,\"queue\":null,\"id\":\"NFbPTf7qPsvKkH75\",\"name\":\"dcat09t\",\"software\":\"Vanilla\",\"softwareId\":\"awFonCo1EWJo3Ch8\",\"type\":\"vanilla\",\"version\":\"1.12.2\",\"deprecated\":false,\"ip\":\"dcat09t.aternos.me\",\"displayAddress\":\"dcat09t.aternos.me:46436\",\"motd\":\" dcat09t!\",\"onlineMode\":false,\"icon\":\"fa-play-circle\",\"dns\":{\"type\":\"SRV\",\"domains\":[\"dcat09t.aternos.me\"],\"host\":\"sawfish.aternos.host\",\"port\":46436,\"ip\":\"185.107.193.120\"},\"maxram\":1700}"} 105 | 106 | # Look at the "players" and "playerlist" fields 107 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | failed='' 4 | 5 | title () { 6 | 7 | RESET='\033[0m' 8 | COLOR='\033[1;36m' 9 | 10 | echo 11 | echo -e "${COLOR}[#] $1$RESET" 12 | } 13 | 14 | error_msg () { 15 | 16 | RESET='\033[0m' 17 | OK='\033[1;32m' 18 | ERR='\033[1;31m' 19 | 20 | if (( $1 )); then 21 | failed+="$2, " 22 | echo -e "${ERR}[X] Found errors$RESET" 23 | else 24 | echo -e "${OK}[V] Passed successfully$RESET" 25 | fi 26 | } 27 | 28 | display_failed() { 29 | 30 | RESET='\033[0m' 31 | FAILED='\033[1;33m' 32 | SUCCESS='\033[1;32m' 33 | 34 | if [[ $failed != '' ]]; then 35 | joined=$(echo -n "$failed" | sed 's/, $//') 36 | echo -e "${FAILED}[!] See output of: $joined$RESET" 37 | else 38 | echo -e "${SUCCESS}[V] All checks were passed successfully$RESET" 39 | fi 40 | } 41 | 42 | title 'Running unit tests...' 43 | python3 -m unittest discover -v ./tests 44 | error_msg $? 'unittest' 45 | 46 | title 'Running pep8 checker...' 47 | python3 -m pycodestyle ./python_aternos 48 | error_msg $? 'pep8' 49 | 50 | title 'Running mypy checker...' 51 | python3 -m mypy ./python_aternos 52 | error_msg $? 'mypy' 53 | 54 | title 'Running pylint checker...' 55 | python3 -m pylint ./python_aternos 56 | error_msg $? 'pylint' 57 | 58 | display_failed 59 | echo 60 | -------------------------------------------------------------------------------- /docs/howto/auth.md: -------------------------------------------------------------------------------- 1 | # How-To 1: Logging in 2 | 3 | ## Intro 4 | Firstly, let's install the library using the command from ["Common install" section](../../#common). 5 | ```bash 6 | pip install python-aternos 7 | ``` 8 | 9 | Also, [register](https://aternos.org/go/) an Aternos account if you haven't one. 10 | Now you are ready. 11 | 12 | ## Authorization with password 13 | Import python-aternos module: 14 | ```python 15 | from python_aternos import Client 16 | ``` 17 | 18 | Then, you can log in to your account using from_credentials method 19 | specifying your username and password. 20 | ```python 21 | at = Client.from_credentials('username', 'password') 22 | ``` 23 | This line will create Client object and save it to `at` variable. 24 | 25 | Okay, we are logged in. What's next? 26 | 27 | ## Servers list 28 | Request the list of your servers: 29 | ```python 30 | servers = at.list_servers() 31 | ``` 32 | 33 | This variable must contain something like: 34 | ```python 35 | [] 36 | ``` 37 | 38 | If you have only one server in your account, 39 | get it by the zero index: 40 | ```python 41 | serv = servers[0] 42 | ``` 43 | 44 | Otherwise, iterate over the list to find it by IP or subdomain: 45 | 46 | ```python 47 | # 1st way: For-loop 48 | # Our server: test.aternos.me 49 | 50 | # Find by IP (domain) 51 | serv = None 52 | for s in servers: 53 | if s.domain == 'test.aternos.me': 54 | serv = s 55 | 56 | # Or find by subdomain 57 | # (part before .aternos.me) 58 | serv = None 59 | for s in servers: 60 | if s.subdomain == 'test': 61 | serv = s 62 | 63 | # Important check 64 | if serv is None: 65 | print('Not found!') 66 | exit() 67 | ``` 68 | 69 | ```python 70 | # 2nd way: Dict comprehension 71 | 72 | serv = { 73 | 'serv': s 74 | for s in servers 75 | if s.subdomain == 'test' 76 | }.get('serv', None) 77 | 78 | if serv is None: 79 | print('Not found!') 80 | exit() 81 | ``` 82 | 83 | `serv` is an AternosServer object. I'll explain it more detailed in the next part. 84 | Now, let's just try to start and stop server: 85 | ```python 86 | # Start 87 | serv.start() 88 | 89 | # Stop 90 | serv.stop() 91 | ``` 92 | 93 | ## Saving session 94 | In the version `v2.0.1` and above, 95 | python-aternos automatically saves and restores session cookie, 96 | so you don't need to do it by yourself now. 97 | 98 | Before, you should save session manually: 99 | ```python 100 | # **** 101 | # This code is useless in new versions, 102 | # because they do it automatically. 103 | # **** 104 | 105 | from python_aternos import Client 106 | 107 | at = Client.from_credentails('username', 'password') 108 | myserv = at.list_servers()[0] 109 | 110 | ... 111 | 112 | at.save_session() 113 | 114 | # Closing python interpreter 115 | # and opening it again 116 | 117 | from python_aternos import Client 118 | 119 | at = Client.restore_session() 120 | myserv = at.list_servers()[0] 121 | 122 | ... 123 | ``` 124 | Function `save_session()` writes the session cookie and the cached servers list to `.aternos` file in your home directory. 125 | `restore_session()` creates a Client object from the session cookie and restores the servers list. 126 | This feature reduces the count of network requests and allows you to log in and request servers much faster. 127 | 128 | If you have created a new server, but it doesn't appear in `list_servers` result, call it with `cache=False` argument. 129 | ```python 130 | # Refresh the list 131 | servers = at.list_servers(cache=False) 132 | ``` 133 | 134 | ## Username, email, password 135 | Change them using the corresponding methods: 136 | ```python 137 | at.change_username('new1cool2username3') 138 | at.change_password('old_password', 'new_password') 139 | at.change_email('new@email.com') 140 | ``` 141 | 142 | ## Hashing passwords 143 | For security reasons, Aternos API takes MD5 hashed passwords, not plain. 144 | 145 | `from_credentials` hashes your credentials and passes to `from_hashed` classmethod. 146 | `change_password` also hashes passwords and calls `change_password_hashed`. 147 | And you can use these methods too. 148 | Python-Aternos contains a handy function `Client.md5encode` that can help you with it. 149 | 150 | ```python 151 | >>> from python_aternos import Client 152 | >>> Client.md5encode('old_password') 153 | '0512f08120c4fef707bd5e2259c537d0' 154 | >>> Client.md5encode('new_password') 155 | '88162595c58939c4ae0b35f39892e6e7' 156 | ``` 157 | 158 | ```python 159 | from python_aternos import Client 160 | 161 | my_passwd = '0512f08120c4fef707bd5e2259c537d0' 162 | new_passwd = '88162595c58939c4ae0b35f39892e6e7' 163 | 164 | at = Client.from_hashed('username', my_passwd) 165 | 166 | at.change_password_hashed(my_passwd, new_passwd) 167 | ``` 168 | 169 | ## Two-Factor Authentication 170 | 2FA is a good idea if you think that the password 171 | is not enough to protect your account. 172 | It was recently added to python-aternos. 173 | 174 | ### Log in with code 175 | Here's how to log in to an account: 176 | ```python 177 | from python_aternos import Client 178 | 179 | at = Client.from_credentials( 180 | 'username', 181 | 'password', 182 | code=123456 183 | ) 184 | # --- OR --- 185 | at = Client.from_hashed( 186 | 'username', 187 | '5f4dcc3b5aa765d61d8327deb882cf99', 188 | code=123456 189 | ) 190 | ``` 191 | Where 123456 must be replaced with 192 | an OTP code from your 2FA application. 193 | 194 | ### Enable 2FA 195 | Also, the library allows to enable it. 196 | 197 | - Request a secret code: 198 | ```python 199 | >>> response = at.qrcode_2fa() 200 | >>> response 201 | {'qrcode': 'data:image/png;base64,iV...', 'secret': '7HSM...'} 202 | ``` 203 | As you can see, Aternos responds with 204 | a QR code picture encoded in base64 205 | and a plain secret code. 206 | 207 | - Enter the secret code into your 2FA application 208 | **OR** save the QR into a file: 209 | ```python 210 | >>> qr = response.get('qrcode', '') 211 | >>> at.save_qr(qr, 'test.png') 212 | ``` 213 | 214 | - Confirm: 215 | ```python 216 | >>> at.enable_2fa(123456) 217 | ``` 218 | Where 123456 is an OTP code from the app. 219 | 220 | ### Disable 2FA 221 | It's pretty easy: 222 | ```python 223 | >>> at.disable_2fa(123456) 224 | ``` 225 | And, of course, pass a real OTP code as an argument. 226 | -------------------------------------------------------------------------------- /docs/howto/config.md: -------------------------------------------------------------------------------- 1 | ## Coming soon 2 | -------------------------------------------------------------------------------- /docs/howto/discord.md: -------------------------------------------------------------------------------- 1 | ## Coming soon 2 | -------------------------------------------------------------------------------- /docs/howto/files.md: -------------------------------------------------------------------------------- 1 | # How-To 4: Files 2 | 3 | ## Intro 4 | In python-aternos, all files on your Minecraft server 5 | are represented as atfile.AternosFile objects. 6 | 7 | They can be accessed through atfm.FileManager instance, 8 | let's assign it to `fm` variable: 9 | ```python 10 | >>> fm = serv.files() 11 | ``` 12 | 13 | ## List directory contents 14 | ```python 15 | >>> root = fm.list_dir('/') 16 | [, ...] 17 | ``` 18 | 19 | ## Get file by its path 20 | ```python 21 | >>> myfile = fm.get_file('/server.properties') 22 | 23 | ``` 24 | 25 | ## File info 26 | AternosFile object can point to 27 | both a file and a directory 28 | and contain almost the same properties and methods. 29 | (So it's more correct to call it "Object in the server's filesystem", 30 | but I chose an easier name for the class.) 31 | 32 | - `path` - Full path to the file **including leading** slash and **without trailing** slash. 33 | - `name` - Filename with extension **without leading** slash. 34 | - `dirname` - Full path to the directory which contains the file **without trailing** slash. 35 | - `is_file` and `is_dir` - File type in boolean. 36 | - `ftype` - File type in `FileType` enum value: 37 | - `FileType.file` 38 | - `FileType.dir` and `FileType.directory` 39 | - `size` - File size in bytes, float. 40 | `0.0` for directories and 41 | `-1.0` when an error occurs. 42 | - `deleteable`, `downloadable` and `editable` are explained in the next section. 43 | 44 | ### File 45 | ```python 46 | >>> f = root[5] 47 | 48 | >>> f.path 49 | '/server.properties' 50 | >>> f.name 51 | 'server.properties' 52 | >>> f.dirname 53 | '' 54 | 55 | >>> f.is_file 56 | False 57 | >>> f.is_dir 58 | True 59 | 60 | >>> from python_aternos import FileType 61 | >>> f.ftype == FileType.file 62 | True 63 | >>> f.ftype == FileType.directory 64 | False 65 | 66 | >>> f.size 67 | 1240.0 68 | 69 | >>> f.deleteable 70 | False 71 | >>> f.downloadable 72 | False 73 | >>> f.editable 74 | False 75 | ``` 76 | 77 | ### Directory 78 | ```python 79 | >>> f = root[2] 80 | 81 | >>> f.path 82 | '/config' 83 | >>> f.name 84 | 'config' 85 | >>> f.dirname 86 | '' 87 | 88 | >>> f.is_file 89 | False 90 | >>> f.is_dir 91 | True 92 | 93 | >>> from python_aternos import FileType 94 | >>> f.ftype == FileType.file 95 | False 96 | >>> f.ftype == FileType.directory 97 | True 98 | >>> f.ftype == FileType.dir 99 | True 100 | 101 | >>> f.size 102 | 0.0 103 | 104 | >>> f.deleteable 105 | False 106 | >>> f.downloadable 107 | True 108 | >>> f.editable 109 | False 110 | ``` 111 | 112 | ## Methods 113 | 114 | - `get_text` returns the file content from the Aternos editor page 115 | (opens when you click on the file on web site). 116 | - `set_text` is the same as "Save" button in the Aternos editor. 117 | - `get_content` requests file downloading and 118 | returns file content in `bytes` (not `str`). 119 | If it is a directory, Aternos returns its content in a ZIP file. 120 | - `set_content` like `set_text`, but takes `bytes` as an argument. 121 | - `delete` removes file. 122 | - `create` creates a new file inside this one 123 | (if it's a directory, otherwise throws RuntimeWarning). 124 | 125 | ### Deletion and downloading rejection 126 | In [Aternos Files tab](https://aternos.org/files), 127 | some files can be removed with a red button, some of them is protected. 128 | You can check if the file is deleteable this way: 129 | ```python 130 | >>> f.deleteable 131 | False 132 | ``` 133 | `delete()` method will warn you if it's undeleteable, 134 | and then you'll probably get `FileError` 135 | because of Aternos deletion denial. 136 | 137 | The same thing with `downloadable`. 138 | ```python 139 | >>> f.downloadable 140 | True 141 | ``` 142 | `get_content()` will warn you if it's undownloadable. 143 | And then you'll get `FileError`. 144 | 145 | And `editable` means that you can click on the file 146 | in Aternos "Files" tab to open editor. 147 | `get_text()` will warn about editing denial. 148 | 149 | ### Creating files 150 | Calling `create` method only available for directories 151 | (check it via `f.is_dir`). 152 | It takes two arguments: 153 | 154 | - `name` - name of a new file, 155 | - `ftype` - type of a new file, must be `FileType` enum value: 156 | - `FileType.file` 157 | - `FileType.dir` or `FileType.directory` 158 | 159 | For example, let's create an empty config 160 | for some Forge mod, I'll call it "testmod". 161 | ```python 162 | # Import enum 163 | from python_aternos import FileType 164 | 165 | # Get configs directory 166 | conf = fm.get_file('/config') 167 | 168 | # Create empty file 169 | conf.create('testmod.toml', FileType.file) 170 | ``` 171 | 172 | ### Editing files 173 | Let's edit `ops.json`. 174 | It contains operators nicknames, 175 | so the code below is the same as [Players API](../players/#list-types). 176 | 177 | ```python 178 | import json 179 | from python_aternos import Client 180 | 181 | at = Client.from_credentials('username', 'password') 182 | serv = at.list_servers()[0] 183 | 184 | fm = serv.files() 185 | ops = fm.get_file('/ops.json') 186 | 187 | # If editable 188 | use_get_text = True 189 | 190 | # Check 191 | if not ops.editable: 192 | 193 | # One more check 194 | if not ops.downloadable: 195 | print('Error') 196 | exit(0) 197 | 198 | # If downloadable 199 | use_get_text = False 200 | 201 | def read(): 202 | 203 | if use_get_text: 204 | return ops.get_text() 205 | else: 206 | return ops.get_content().decode('utf-8') 207 | 208 | def write(content): 209 | 210 | # set_text and set_content 211 | # uses the same URLs, 212 | # so there's no point in checking 213 | # if the file is editable/downloadable 214 | 215 | # Code for set_text: 216 | #ops.set_text(content) 217 | 218 | # Code for set_content: 219 | # Convert the str to bytes 220 | content = content.encode('utf-8') 221 | # Edit 222 | ops.set_content(content) 223 | 224 | # ops.json contains an empty list [] by default 225 | oper_raw = read() 226 | 227 | # Convert it to a Python list 228 | oper_lst = json.loads(oper_raw) 229 | 230 | # Add an operator 231 | oper_lst.append('DarkCat09') 232 | 233 | # Convert back to JSON 234 | oper_new = json.dumps(oper_lst) 235 | 236 | # Write 237 | ops.write(oper_new) 238 | ``` 239 | -------------------------------------------------------------------------------- /docs/howto/players.md: -------------------------------------------------------------------------------- 1 | # How-To 3: Players lists 2 | You can add a player to operators, 3 | include into the whitelist or ban him 4 | using this feature. 5 | 6 | ## Common usage 7 | It's pretty easy: 8 | ```python 9 | from python_aternos import Client, Lists 10 | 11 | ... 12 | 13 | whitelist = serv.players(Lists.whl) 14 | 15 | whitelist.add('jeb_') 16 | whitelist.remove('Notch') 17 | 18 | whitelist.list_players() 19 | # ['DarkCat09', 'jeb_'] 20 | ``` 21 | 22 | ## List types 23 | 24 | | Name | Enum key | 25 | |:----------:|:---------:| 26 | | Whitelist |`Lists.whl`| 27 | | Operators |`Lists.ops`| 28 | | Banned |`Lists.ban`| 29 | |Banned by IP|`Lists.ips`| 30 | 31 | For example, I want to ban someone: 32 | ```python 33 | serv.players(Lists.ban).add('someone') 34 | ``` 35 | 36 | And give myself the operator rights: 37 | ```python 38 | serv.players(Lists.ops).add('DarkCat09') 39 | ``` 40 | 41 | Unban someone: 42 | ```python 43 | serv.players(Lists.ban).remove('someone') 44 | ``` 45 | 46 | Unban someone who I banned by IP: 47 | ```python 48 | serv.players(Lists.ips).remove('anyone') 49 | ``` 50 | 51 | ## Caching 52 | If `list_players` doesn't show added players, call it with `cache=False` argument, like list_servers. 53 | ```python 54 | lst = serv.players(Lists.ops) 55 | lst.list_players(cache=False) 56 | # ['DarkCat09', 'jeb_'] 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/howto/server.md: -------------------------------------------------------------------------------- 1 | # How-To 2: Controlling Minecraft server 2 | 3 | In the previous part we've logged into an account and have started a server. 4 | But python-aternos can do much more. 5 | 6 | ## Basic methods 7 | ```python 8 | from python_aternos import Client 9 | 10 | at = Client.from_credentials('username', 'password') 11 | serv = at.list_servers()[0] 12 | 13 | # Start 14 | serv.start() 15 | 16 | # Stop 17 | serv.stop() 18 | 19 | # Restart 20 | serv.restart() 21 | 22 | # Cancel starting 23 | serv.cancel() 24 | 25 | # Confirm starting 26 | # at the end of a queue 27 | serv.confirm() 28 | ``` 29 | 30 | ## Starting 31 | ### Arguments 32 | `start()` can be called with arguments: 33 | 34 | - headstart (bool): Start server in headstart mode 35 | which allows you to skip all the queue. 36 | - accepteula (bool): Automatically accept Mojang EULA. 37 | 38 | If you want to launch your server instantly, use this code: 39 | ```python 40 | serv.start(headstart=True) 41 | ``` 42 | 43 | ### Errors 44 | `start()` raises `ServerStartError` if Aternos denies request. 45 | This object contains an error code, on which depends an error message. 46 | 47 | - EULA was not accepted (code: `eula`) - 48 | remove `accepteula=False` or run `serv.eula()` before the server startup. 49 | - Server is already running (code: `already`) - 50 | you don't need to start the server, it is online. 51 | - Incorrect software version installed (code: `wrongversion`) - 52 | if you have *somehow* installed non-existent software version (e.g. `Vanilla 2.16.5`). 53 | - File server is unavailable (code: `file`) - 54 | problems on Aternos servers, view [https://status.aternos.gmbh](https://status.aternos.gmbh) 55 | - Available storage size limit has been reached (code: `size`) - 56 | files on your Minecraft server have reached 4GB limit 57 | (for exmaple, too much mods or loaded chunks). 58 | 59 | Always wrap `start` into try-except. 60 | ```python 61 | from python_aternos import ServerStartError 62 | 63 | ... 64 | 65 | try: 66 | serv.start() 67 | except ServerStartError as err: 68 | print(err.code) # already 69 | print(err.message) # Server is already running 70 | ``` 71 | 72 | ## Cancellation 73 | Server launching can be cancelled only when you are waiting in a queue. 74 | After queue, when the server starts and writes something to the log, 75 | you can just `stop()` it, **not** `cancel()`. 76 | 77 | ## Server info 78 | ```python 79 | >>> serv.address 80 | 'test.aternos.me:15172' 81 | 82 | >>> serv.domain 83 | 'test.aternos.me' 84 | 85 | >>> serv.subdomain 86 | 'test' 87 | 88 | >>> serv.port 89 | 15172 90 | 91 | >>> from python_aternos import Edition 92 | >>> serv.edition 93 | 0 94 | >>> serv.edition == Edition.java 95 | True 96 | >>> serv.edition == Edition.bedrock 97 | False 98 | 99 | >>> serv.software 100 | 'Forge' 101 | >>> serv.version 102 | '1.16.5 (36.2.34)' 103 | 104 | >>> serv.players_list 105 | ['DarkCat09', 'jeb_'] 106 | >>> serv.players_count 107 | 2 108 | >>> serv.slots 109 | 20 110 | 111 | >>> print('Online:', serv.players_count, 'of', serv.slots) 112 | Online: 2 of 20 113 | 114 | >>> serv.motd 115 | '§7Welcome to the §9Test Server§7!' 116 | 117 | >>> from python_aternos import Status 118 | >>> serv.css_class 119 | 'online' 120 | >>> serv.status 121 | 'online' 122 | >>> serv.status_num 123 | 1 124 | >>> serv.status_num == Status.on 125 | True 126 | >>> serv.status_num == Status.off 127 | False 128 | >>> serv.status_num == Status.starting 129 | False 130 | 131 | >>> serv.restart() 132 | 133 | # Title on the web site: "Loading" 134 | >>> serv.css_class 135 | 'loading' 136 | >>> serv.status 137 | 'loading' 138 | >>> serv.status_num 139 | 6 140 | >>> serv.status_num == Status.loading 141 | True 142 | >>> serv.status_num == Status.preparing 143 | False 144 | >>> serv.status_num == Status.starting 145 | False 146 | 147 | # Title on the web site: "Preparing" 148 | >>> serv.css_class 149 | 'loading' 150 | >>> serv.status 151 | 'preparing' 152 | >>> serv.status_num 153 | 10 154 | >>> serv.status_num == Status.preparing 155 | True 156 | >>> serv.status_num == Status.starting 157 | False 158 | >>> serv.status_num == Status.on 159 | False 160 | 161 | # Title on the web site: "Starting" 162 | >>> serv.css_class 163 | 'loading starting' 164 | >>> serv.status 165 | 'starting' 166 | >>> serv.status_num 167 | 2 168 | >>> serv.status_num == Status.starting 169 | True 170 | >>> serv.status_num == Status.on 171 | False 172 | 173 | >>> serv.ram 174 | 2600 175 | ``` 176 | 177 | ## Changing subdomain and MOTD 178 | To change the server's subdomain or Message-of-the-Day, 179 | just assign a new value to the corresponding fields: 180 | ```python 181 | serv.subdomain = 'new-test-server123' 182 | serv.motd = 'Welcome to the New Test Server!' 183 | ``` 184 | 185 | ## Updating status 186 | Python-Aternos don't refresh server information by default. 187 | This can be done with [WebSockets API](/howto/websocket) automatically 188 | (but it will be explained later in the 6th part of how-to guide), 189 | or with `fetch()` method manually (much easier). 190 | 191 | `fetch()` is also called when an AternosServer object is created 192 | to get this info about the server: 193 | 194 | - full address, 195 | - MOTD, 196 | - software, 197 | - connected players, 198 | - status, 199 | - etc. 200 | 201 | Use it if you want to see the new data *one time*: 202 | ```python 203 | import time 204 | from python_aternos import Client 205 | 206 | at = Client.from_credentials('username', 'password') 207 | serv = at.list_servers()[0] 208 | 209 | # Start 210 | serv.start() 211 | # Wait 10 sec 212 | time.sleep(10) 213 | # Check 214 | serv.fetch() 215 | print('Server is', serv.status) # Server is online 216 | ``` 217 | But this method is **not** a good choice if you want to get *real-time* updates. 218 | Read [How-To 6: Real-time updates](/howto/websocket) about WebSockets API 219 | and use it instead of refreshing data in a while-loop. 220 | 221 | ## Countdown 222 | Aternos stops a server when there are no players connected. 223 | You can get the remained time in seconds using `serv.countdown`. 224 | 225 | For example: 226 | ```python 227 | # Start 228 | serv.start() 229 | 230 | # Get the countdown value 231 | print(serv.countdown, 'seconds') 232 | # -1 seconds 233 | # means "null" in countdown field 234 | 235 | # Wait for start up 236 | time.sleep(10) 237 | 238 | # Refresh info 239 | serv.fetch() 240 | # Get countdown value 241 | print(serv.countdown, 'seconds') 242 | # 377 seconds 243 | 244 | # Check if countdown changes 245 | time.sleep(10) 246 | serv.fetch() 247 | print(serv.countdown, 'seconds') 248 | # 367 seconds 249 | 250 | # --- 251 | # Convert to minutes and seconds 252 | mins, secs = divmod(serv.countdown, 60) 253 | print(f'{mins}:{secs:02}') # 6:07 254 | # OR 255 | cd = serv.countdown 256 | print(f'{cd // 60}:{cd % 60:02}') # 6:07 257 | ``` 258 | -------------------------------------------------------------------------------- /docs/howto/websocket.md: -------------------------------------------------------------------------------- 1 | ## Coming soon 2 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 | Python Aternos Logo 3 |

4 | Python Aternos 5 | 19 |

20 |
21 | 22 | An unofficial Aternos API written in Python. 23 | It uses [aternos](https://aternos.org/)' private API and html parsing. 24 | 25 | Python Aternos supports: 26 | 27 | - Logging in to account with password (plain or hashed) or `ATERNOS_SESSION` cookie value. 28 | - Saving session to the file and restoring. 29 | - Changing username, email and password. 30 | - Parsing Minecraft servers list. 31 | - Parsing server info by its ID. 32 | - Starting/stoping server, restarting, confirming/cancelling launch. 33 | - Updating server info in real-time (view WebSocket API). 34 | - Changing server subdomain and MOTD (message-of-the-day). 35 | - Managing files, settings, players (whitelist, operators, etc.) 36 | 37 | > **Warning** 38 | > 39 | > According to the Aternos' [Terms of Service §5.2e](https://aternos.gmbh/en/aternos/terms#:~:text=Automatically%20accessing%20our%20website%20or%20automating%20actions%20on%20our%20website.), 40 | > you must not use any software or APIs for automated access, 41 | > beacuse they don't receive money from advertisting in this case. 42 | > 43 | > I always try to hide automated python-aternos requests 44 | > using browser-specific headers/cookies, 45 | > but you should make backups to restore your world 46 | > if Aternos detects violation of ToS and bans your account 47 | > (view issues [#16](https://github.com/DarkCat09/python-aternos/issues/16) 48 | > and [#46](https://github.com/DarkCat09/python-aternos/issues/46)). 49 | 50 | ## Install 51 | 52 | ### Common 53 | ```bash 54 | $ pip install python-aternos 55 | ``` 56 | > **Note** for Windows users 57 | > 58 | > Install `lxml` package from [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#lxml) 59 | > if you have problems with it, and then execute: 60 | > `pip install --no-deps python-aternos` 61 | 62 | ### Development 63 | ```bash 64 | $ git clone https://github.com/DarkCat09/python-aternos.git 65 | $ cd python-aternos 66 | $ pip install -e . 67 | ``` 68 | 69 | ## Usage 70 | To use Aternos API in your Python script, import it 71 | and login with your username and password or MD5. 72 | 73 | Then request the servers list using `list_servers()`. 74 | You can start/stop your Aternos server, calling `start()` or `stop()`. 75 | 76 | Here is an example how to use the API: 77 | ```python 78 | # Import 79 | from python_aternos import Client 80 | 81 | # Log in 82 | aternos = Client.from_credentials('example', 'test123') 83 | # ----OR---- 84 | aternos = Client.from_hashed('example', 'cc03e747a6afbbcbf8be7668acfebee5') 85 | # ----OR---- 86 | aternos = Client.from_session('ATERNOS_SESSION cookie') 87 | 88 | # Returns AternosServer list 89 | servs = aternos.list_servers() 90 | 91 | # Get the first server by the 0 index 92 | myserv = servs[0] 93 | 94 | # Start 95 | myserv.start() 96 | # Stop 97 | myserv.stop() 98 | 99 | # You can also find server by IP 100 | testserv = None 101 | for serv in servs: 102 | if serv.address == 'test.aternos.org': 103 | testserv = serv 104 | 105 | if testserv is not None: 106 | # Prints the server software and its version 107 | # (for example, "Vanilla 1.12.2") 108 | print(testserv.software, testserv.version) 109 | # Starts server 110 | testserv.start() 111 | ``` 112 | 113 | ## [More examples](https://github.com/DarkCat09/python-aternos/tree/main/examples) 114 | 115 | ## [Documentation](https://python-aternos.codeberg.page/) 116 | 117 | ## [How-To Guide](https://python-aternos.codeberg.page/howto/auth) 118 | 119 | ## Changelog 120 | |Version|Description | 121 | |:-----:|:-----------| 122 | |v0.1|The first release.| 123 | |v0.2|Fixed import problem.| 124 | |v0.3|Implemented files API, added typization.| 125 | |v0.4|Implemented configuration API, some bugfixes.| 126 | |v0.5|The API was updated corresponding to new Aternos security methods. Huge thanks to [lusm554](https://github.com/lusm554).| 127 | |**v0.6/v1.0.0**|Code refactoring, websockets API and session saving to prevent detecting automation access.| 128 | |v1.0.x|Lots of bugfixes, changed versioning (SemVer).| 129 | |v1.1.x|Documentation, unit tests, pylint, bugfixes, changes in atwss.| 130 | |**v1.1.2/v2.0.0**|Solution for [#25](https://github.com/DarkCat09/python-aternos/issues/25) (Cloudflare bypassing), bugfixes in JS parser.| 131 | |v2.0.x|Documentation, automatically saving/restoring session, improvements in Files API.| 132 | |v2.1.x|Fixes in websockets API, atconnect (including cookie refreshing fix). Supported captcha solving services (view [#52](https://github.com/DarkCat09/python-aternos/issues/52)).| 133 | |**v2.2.x**|Node.JS interpreter support.| 134 | |v3.0.x|Full implementation of config and software API.| 135 | |v3.1.x|Shared access API and Google Drive backups.| 136 | 137 | ## License 138 | [License Notice:](https://github.com/DarkCat09/python-aternos/blob/main/NOTICE) 139 | ``` 140 | Copyright 2021-2022 All contributors 141 | 142 | Licensed under the Apache License, Version 2.0 (the "License"); 143 | you may not use this file except in compliance with the License. 144 | You may obtain a copy of the License at 145 | 146 | http://www.apache.org/licenses/LICENSE-2.0 147 | 148 | Unless required by applicable law or agreed to in writing, software 149 | distributed under the License is distributed on an "AS IS" BASIS, 150 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 151 | See the License for the specific language governing permissions and 152 | limitations under the License. 153 | ``` 154 | -------------------------------------------------------------------------------- /docs/reference/atclient.md: -------------------------------------------------------------------------------- 1 | ## `atclient` (Entry point) 2 | ### ::: python_aternos.atclient 3 | -------------------------------------------------------------------------------- /docs/reference/atconf.md: -------------------------------------------------------------------------------- 1 | ## atconf 2 | ### ::: python_aternos.atconf 3 | -------------------------------------------------------------------------------- /docs/reference/atconnect.md: -------------------------------------------------------------------------------- 1 | ## atconnect 2 | ### ::: python_aternos.atconnect 3 | -------------------------------------------------------------------------------- /docs/reference/aterrors.md: -------------------------------------------------------------------------------- 1 | ## aterrors 2 | ### ::: python_aternos.aterrors 3 | -------------------------------------------------------------------------------- /docs/reference/atfile.md: -------------------------------------------------------------------------------- 1 | ## atfile 2 | ### ::: python_aternos.atfile 3 | -------------------------------------------------------------------------------- /docs/reference/atfm.md: -------------------------------------------------------------------------------- 1 | ## atfm 2 | ### ::: python_aternos.atfm 3 | -------------------------------------------------------------------------------- /docs/reference/atjsparse.md: -------------------------------------------------------------------------------- 1 | ## atjsparse 2 | ### ::: python_aternos.atjsparse 3 | -------------------------------------------------------------------------------- /docs/reference/atplayers.md: -------------------------------------------------------------------------------- 1 | ## `atplayers` 2 | ### ::: python_aternos.atplayers 3 | -------------------------------------------------------------------------------- /docs/reference/atserver.md: -------------------------------------------------------------------------------- 1 | ## `atserver` 2 | ### ::: python_aternos.atserver 3 | -------------------------------------------------------------------------------- /docs/reference/atwss.md: -------------------------------------------------------------------------------- 1 | ## atwss 2 | ### ::: python_aternos.atwss 3 | -------------------------------------------------------------------------------- /examples/console_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aioconsole 3 | from getpass import getpass 4 | from python_aternos import Client, atwss 5 | 6 | user = input('Username: ') 7 | pswd = getpass('Password: ') 8 | resp = input('Show responses? ').upper() == 'Y' 9 | 10 | atclient = Client() 11 | aternos = atclient.account 12 | atclient.login(user, pswd) 13 | 14 | s = aternos.list_servers()[0] 15 | socket = s.wss() 16 | 17 | if resp: 18 | @socket.wssreceiver(atwss.Streams.console) 19 | async def console(msg): 20 | print('<', msg) 21 | 22 | 23 | async def main(): 24 | s.start() 25 | await asyncio.gather( 26 | socket.connect(), 27 | commands() 28 | ) 29 | 30 | 31 | async def commands(): 32 | while True: 33 | cmd = await aioconsole.ainput('> ') 34 | if cmd.strip() == '': 35 | continue 36 | await socket.send({ 37 | 'stream': 'console', 38 | 'type': 'command', 39 | 'data': cmd 40 | }) 41 | 42 | asyncio.run(main()) 43 | -------------------------------------------------------------------------------- /examples/files_example.py: -------------------------------------------------------------------------------- 1 | from getpass import getpass 2 | from python_aternos import Client 3 | 4 | user = input('Username: ') 5 | pswd = getpass('Password: ') 6 | 7 | atclient = Client() 8 | aternos = atclient.account 9 | atclient.login(user, pswd) 10 | 11 | s = aternos.list_servers()[0] 12 | files = s.files() 13 | 14 | while True: 15 | 16 | inp = input('> ').strip() 17 | cmd = inp.lower() 18 | 19 | if cmd == 'help': 20 | print( 21 | '''Commands list: 22 | help - show this message 23 | quit - exit from the script 24 | world - download the world 25 | list [path] - show directory (or root) contents''' 26 | ) 27 | 28 | if cmd == 'quit': 29 | break 30 | 31 | if cmd.startswith('list'): 32 | path = inp[4:].strip() 33 | directory = files.list_dir(path) 34 | 35 | print(path, 'contains:') 36 | for file in directory: 37 | print('\t' + file.name) 38 | 39 | if cmd == 'world': 40 | file_w = files.get_file('/world') 41 | 42 | if file_w is None: 43 | print('Cannot create /world directory object') 44 | continue 45 | 46 | with open('world.zip', 'wb') as f: 47 | f.write(file_w.get_content()) 48 | -------------------------------------------------------------------------------- /examples/info_example.py: -------------------------------------------------------------------------------- 1 | from getpass import getpass 2 | from python_aternos import Client, atserver 3 | 4 | user = input('Username: ') 5 | pswd = getpass('Password: ') 6 | 7 | atclient = Client() 8 | aternos = atclient.account 9 | atclient.login(user, pswd) 10 | 11 | srvs = aternos.list_servers() 12 | 13 | for srv in srvs: 14 | print() 15 | print('***', srv.servid, '***') 16 | srv.fetch() 17 | print(srv.domain) 18 | print(srv.motd) 19 | print('*** Status:', srv.status) 20 | print('*** Full address:', srv.address) 21 | print('*** Port:', srv.port) 22 | print('*** Name:', srv.subdomain) 23 | print('*** Minecraft:', srv.software, srv.version) 24 | print('*** IsBedrock:', srv.edition == atserver.Edition.bedrock) 25 | print('*** IsJava:', srv.edition == atserver.Edition.java) 26 | 27 | print() 28 | -------------------------------------------------------------------------------- /examples/start_example.py: -------------------------------------------------------------------------------- 1 | from getpass import getpass 2 | from python_aternos import Client 3 | 4 | user = input('Username: ') 5 | pswd = getpass('Password: ') 6 | 7 | atclient = Client() 8 | aternos = atclient.account 9 | atclient.login(user, pswd) 10 | 11 | srvs = aternos.list_servers() 12 | print(srvs) 13 | 14 | s = srvs[0] 15 | s.start() 16 | -------------------------------------------------------------------------------- /examples/websocket_args_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from getpass import getpass 3 | 4 | from typing import Tuple, Dict, Any 5 | 6 | from python_aternos import Client, Streams 7 | 8 | 9 | # Request credentials 10 | user = input('Username: ') 11 | pswd = getpass('Password: ') 12 | 13 | # Instantiate Client 14 | atclient = Client() 15 | aternos = atclient.account 16 | 17 | # Enable debug logging 18 | logs = input('Show detailed logs? (y/n) ').strip().lower() == 'y' 19 | if logs: 20 | atclient.debug = True 21 | 22 | # Authenticate 23 | atclient.login(user, pswd) 24 | 25 | server = aternos.list_servers()[0] 26 | socket = server.wss() 27 | 28 | 29 | # Handler for console messages 30 | @socket.wssreceiver(Streams.console, ('Server 1',)) # type: ignore 31 | async def console( 32 | msg: Dict[Any, Any], 33 | args: Tuple[str]) -> None: 34 | 35 | print(args[0], 'received', msg) 36 | 37 | 38 | # Main function 39 | async def main() -> None: 40 | server.start() 41 | await socket.connect() 42 | await asyncio.create_task(loop()) 43 | 44 | 45 | # Keepalive 46 | async def loop() -> None: 47 | while True: 48 | await asyncio.Future() 49 | 50 | 51 | asyncio.run(main()) 52 | -------------------------------------------------------------------------------- /examples/websocket_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from getpass import getpass 3 | from python_aternos import Client, atwss 4 | 5 | user = input('Username: ') 6 | pswd = getpass('Password: ') 7 | 8 | atclient = Client() 9 | aternos = atclient.account 10 | atclient.login(user, pswd) 11 | 12 | s = aternos.list_servers()[0] 13 | socket = s.wss() 14 | 15 | 16 | @socket.wssreceiver(atwss.Streams.console) 17 | async def console(msg): 18 | print('Received:', msg) 19 | 20 | 21 | async def main(): 22 | s.start() 23 | await socket.connect() 24 | await asyncio.create_task(loop()) 25 | 26 | 27 | async def loop(): 28 | while True: 29 | await asyncio.sleep(1) 30 | 31 | asyncio.run(main()) 32 | -------------------------------------------------------------------------------- /examples/websocket_status_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from getpass import getpass 3 | 4 | from typing import Tuple, Dict, Any 5 | 6 | from python_aternos import Client, Streams 7 | 8 | 9 | # Request credentials 10 | user = input('Username: ') 11 | pswd = getpass('Password: ') 12 | 13 | # Instantiate Client 14 | atclient = Client() 15 | aternos = atclient.account 16 | 17 | # Enable debug logging 18 | logs = input('Show detailed logs? (y/n) ').strip().lower() == 'y' 19 | if logs: 20 | atclient.debug = True 21 | 22 | # Authenticate 23 | atclient.login(user, pswd) 24 | 25 | server = aternos.list_servers()[0] 26 | socket = server.wss() 27 | 28 | 29 | # Handler for server status 30 | @socket.wssreceiver(Streams.status, ('Server 1',)) # type: ignore 31 | async def state( 32 | msg: Dict[Any, Any], 33 | args: Tuple[str]) -> None: 34 | 35 | # For debugging 36 | print(args[0], 'received', len(msg), 'symbols') 37 | 38 | # Write new info dictionary 39 | server._info = msg 40 | 41 | # Server 1 test is online 42 | print( 43 | args[0], 44 | server.subdomain, 45 | 'is', 46 | server.status 47 | ) 48 | 49 | # Server 1 players: ['DarkCat09', 'someone'] 50 | print( 51 | args[0], 52 | 'players:', 53 | server.players_list 54 | ) 55 | 56 | 57 | # Main function 58 | async def main() -> None: 59 | server.start() 60 | await socket.connect() 61 | await asyncio.create_task(loop()) 62 | 63 | 64 | # Keepalive 65 | async def loop() -> None: 66 | while True: 67 | await asyncio.Future() 68 | 69 | 70 | asyncio.run(main()) 71 | -------------------------------------------------------------------------------- /logo/aternos_400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkCat09/python-aternos/04ba96108e723b2ef2229255d3be9ea99af1a412/logo/aternos_400.png -------------------------------------------------------------------------------- /logo/aternos_800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkCat09/python-aternos/04ba96108e723b2ef2229255d3be9ea99af1a412/logo/aternos_800.png -------------------------------------------------------------------------------- /logo/square_400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkCat09/python-aternos/04ba96108e723b2ef2229255d3be9ea99af1a412/logo/square_400.png -------------------------------------------------------------------------------- /logo/square_800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkCat09/python-aternos/04ba96108e723b2ef2229255d3be9ea99af1a412/logo/square_800.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Python-Aternos 2 | 3 | theme: 4 | name: readthedocs 5 | sticky_navigation: false 6 | include_homepage_in_sidebar: false 7 | prev_next_buttons_location: both 8 | 9 | markdown_extensions: 10 | - toc: 11 | permalink: '#' 12 | 13 | plugins: 14 | - search 15 | - mkdocstrings: 16 | handlers: 17 | python: 18 | options: 19 | show_source: false 20 | 21 | nav: 22 | - Home: 'index.md' 23 | - 'How-To Guide': 24 | - 'Logging in': 'howto/auth.md' 25 | - 'Servers': 'howto/server.md' 26 | - 'Whitelist': 'howto/players.md' 27 | - 'Files': 'howto/files.md' 28 | - 'Settings': 'howto/config.md' 29 | - 'Real-time updates': 'howto/websocket.md' 30 | - 'Discord bot': 'howto/discord.md' 31 | - 'API Reference': 32 | - atclient: 'reference/atclient.md' 33 | - atserver: 'reference/atserver.md' 34 | - atplayers: 'reference/atplayers.md' 35 | - atconf: 'reference/atconf.md' 36 | - atfm: 'reference/atfm.md' 37 | - atfile: 'reference/atfile.md' 38 | - atconnect: 'reference/atconnect.md' 39 | - atjsparse: 'reference/atjsparse.md' 40 | - aterrors: 'reference/aterrors.md' 41 | - atwss: 'reference/atwss.md' 42 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | analyse-fallback-blocks=no 3 | clear-cache-post-run=no 4 | extension-pkg-allow-list= 5 | extension-pkg-whitelist= 6 | fail-on= 7 | fail-under=10 8 | ignore=CVS 9 | ignore-paths= 10 | ignore-patterns=^\.# 11 | ignored-modules= 12 | jobs=4 13 | limit-inference-results=100 14 | load-plugins= 15 | persistent=yes 16 | py-version=3.10 17 | recursive=no 18 | suggestion-mode=yes 19 | unsafe-load-any-extension=no 20 | 21 | [BASIC] 22 | argument-naming-style=snake_case 23 | attr-naming-style=snake_case 24 | bad-names=foo, 25 | bar, 26 | baz, 27 | toto, 28 | tutu, 29 | tata 30 | bad-names-rgxs= 31 | class-attribute-naming-style=any 32 | class-const-naming-style=any 33 | class-naming-style=PascalCase 34 | const-naming-style=UPPER_CASE 35 | docstring-min-length=10 36 | function-naming-style=snake_case 37 | good-names=i, 38 | j, 39 | k, 40 | f, 41 | s, 42 | js, 43 | ex, 44 | Run, 45 | _ 46 | good-names-rgxs= 47 | include-naming-hint=no 48 | inlinevar-naming-style=any 49 | method-naming-style=snake_case 50 | module-naming-style=snake_case 51 | name-group= 52 | no-docstring-rgx=^_ 53 | property-classes=abc.abstractproperty 54 | variable-naming-style=snake_case 55 | 56 | [CLASSES] 57 | check-protected-access-in-special-methods=no 58 | defining-attr-methods=__init__, 59 | __new__, 60 | setUp, 61 | __post_init__ 62 | exclude-protected=_asdict, 63 | _fields, 64 | _replace, 65 | _source, 66 | _make 67 | valid-classmethod-first-arg=cls 68 | valid-metaclass-classmethod-first-arg=mcs 69 | 70 | [DESIGN] 71 | exclude-too-few-public-methods= 72 | ignored-parents= 73 | max-args=10 74 | max-attributes=10 75 | max-bool-expr=5 76 | max-branches=12 77 | max-locals=20 78 | max-parents=7 79 | max-public-methods=31 80 | max-returns=6 81 | max-statements=50 82 | min-public-methods=2 83 | 84 | [EXCEPTIONS] 85 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 86 | 87 | [FORMAT] 88 | expected-line-ending-format= 89 | ignore-long-lines=^\s*(# )??$ 90 | indent-after-paren=4 91 | indent-string=' ' 92 | max-line-length=100 93 | max-module-lines=1000 94 | single-line-class-stmt=no 95 | single-line-if-stmt=no 96 | 97 | [IMPORTS] 98 | allow-any-import-level= 99 | allow-reexport-from-package=no 100 | allow-wildcard-with-all=no 101 | deprecated-modules= 102 | ext-import-graph= 103 | import-graph= 104 | int-import-graph= 105 | known-standard-library= 106 | known-third-party=enchant 107 | preferred-modules= 108 | 109 | [LOGGING] 110 | logging-format-style=old 111 | logging-modules=logging 112 | 113 | [MESSAGES CONTROL] 114 | confidence=HIGH, 115 | CONTROL_FLOW, 116 | INFERENCE, 117 | INFERENCE_FAILURE, 118 | UNDEFINED 119 | disable=raw-checker-failed, 120 | bad-inline-option, 121 | locally-disabled, 122 | file-ignored, 123 | suppressed-message, 124 | useless-suppression, 125 | deprecated-pragma, 126 | use-symbolic-message-instead, 127 | no-member 128 | enable=c-extension-no-member 129 | 130 | [METHOD_ARGS] 131 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 132 | 133 | [MISCELLANEOUS] 134 | notes=FIXME, 135 | XXX, 136 | TODO 137 | notes-rgx= 138 | 139 | [REFACTORING] 140 | max-nested-blocks=5 141 | never-returning-functions=sys.exit,argparse.parse_error 142 | 143 | [REPORTS] 144 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 145 | msg-template= 146 | reports=no 147 | score=yes 148 | 149 | [SIMILARITIES] 150 | ignore-comments=yes 151 | ignore-docstrings=yes 152 | ignore-imports=yes 153 | ignore-signatures=yes 154 | min-similarity-lines=4 155 | 156 | [SPELLING] 157 | max-spelling-suggestions=4 158 | spelling-dict= 159 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 160 | spelling-ignore-words= 161 | spelling-private-dict-file= 162 | spelling-store-unknown-words=no 163 | 164 | [STRING] 165 | check-quote-consistency=no 166 | check-str-concat-over-line-jumps=no 167 | 168 | [TYPECHECK] 169 | contextmanager-decorators=contextlib.contextmanager 170 | generated-members= 171 | ignore-none=yes 172 | ignore-on-opaque-inference=yes 173 | ignored-checks-for-mixins=no-member, 174 | not-async-context-manager, 175 | not-context-manager, 176 | attribute-defined-outside-init 177 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 178 | missing-member-hint=yes 179 | missing-member-hint-distance=1 180 | missing-member-max-choices=1 181 | mixin-class-rgx=.*[Mm]ixin 182 | signature-mutators= 183 | 184 | [VARIABLES] 185 | additional-builtins= 186 | allow-global-unused-variables=yes 187 | allowed-redefined-builtins= 188 | callbacks=cb_, 189 | _cb 190 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 191 | ignored-argument-names=_.*|^ignored_|^unused_ 192 | init-import=no 193 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 194 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /python_aternos/__init__.py: -------------------------------------------------------------------------------- 1 | """Init""" 2 | 3 | from .atclient import Client 4 | from .atserver import AternosServer 5 | from .atserver import Edition 6 | from .atserver import Status 7 | from .atplayers import PlayersList 8 | from .atplayers import Lists 9 | from .atwss import Streams 10 | from .atjsparse import Js2PyInterpreter 11 | from .atjsparse import NodeInterpreter 12 | -------------------------------------------------------------------------------- /python_aternos/ataccount.py: -------------------------------------------------------------------------------- 1 | """Methods related to an Aternos account 2 | including servers page parsing""" 3 | 4 | import re 5 | import base64 6 | 7 | from typing import List, Dict 8 | from typing import TYPE_CHECKING 9 | 10 | import lxml.html 11 | 12 | from .atlog import log 13 | from .atmd5 import md5encode 14 | 15 | from .atconnect import AternosConnect 16 | from .atconnect import BASE_URL, AJAX_URL 17 | 18 | from .atserver import AternosServer 19 | 20 | if TYPE_CHECKING: 21 | from .atclient import Client 22 | 23 | 24 | ACCOUNT_URL = f'{AJAX_URL}/account' 25 | email_re = re.compile( 26 | r'^[A-Za-z0-9\-_+.]+@[A-Za-z0-9\-_+.]+\.[A-Za-z0-9\-]+$|^$' 27 | ) 28 | 29 | 30 | class AternosAccount: 31 | """Methods related to an Aternos account 32 | including servers page parsing""" 33 | 34 | def __init__(self, atclient: 'Client') -> None: 35 | """Should not be instantiated manually, 36 | the entrypoint is `atclient.Client` 37 | 38 | Args: 39 | atconn (AternosConnect): AternosConnect object 40 | """ 41 | 42 | self.atclient = atclient 43 | self.atconn: AternosConnect = atclient.atconn 44 | 45 | self.parsed = False 46 | self.servers: List[AternosServer] = [] 47 | 48 | def list_servers(self, cache: bool = True) -> List[AternosServer]: 49 | """Parses a servers list 50 | 51 | Args: 52 | cache (bool, optional): If the function should use 53 | cached servers list (recommended) 54 | 55 | Returns: 56 | List of AternosServer objects 57 | """ 58 | 59 | if cache and self.parsed: 60 | return self.servers 61 | 62 | serverspage = self.atconn.request_cloudflare( 63 | f'{BASE_URL}/servers/', 'GET' 64 | ) 65 | serverstree = lxml.html.fromstring(serverspage.content) 66 | 67 | servers = serverstree.xpath( 68 | '//div[@class="server-body"]/@data-id' 69 | ) 70 | self.refresh_servers(servers) 71 | 72 | # Update session file (add servers) 73 | try: 74 | self.atclient.save_session(self.atclient.saved_session) 75 | except OSError as err: 76 | log.warning('Unable to save servers list to file: %s', err) 77 | 78 | return self.servers 79 | 80 | def refresh_servers(self, ids: List[str]) -> None: 81 | """Replaces the cached servers list 82 | creating AternosServer objects by given IDs 83 | 84 | Args: 85 | ids (List[str]): Servers unique identifiers 86 | """ 87 | 88 | self.servers = [] 89 | for s in ids: 90 | 91 | servid = s.strip() 92 | if servid == '': 93 | continue 94 | 95 | log.debug('Adding server %s', servid) 96 | srv = AternosServer(servid, self.atconn) 97 | self.servers.append(srv) 98 | 99 | self.parsed = True 100 | 101 | def get_server(self, servid: str) -> AternosServer: 102 | """Creates a server object from the server ID. 103 | Use this instead of `list_servers` if you know 104 | the server IDentifier 105 | 106 | Returns: 107 | AternosServer object 108 | """ 109 | 110 | return AternosServer(servid, self.atconn) 111 | 112 | def change_username(self, value: str) -> None: 113 | """Changes a username in your Aternos account 114 | 115 | Args: 116 | value (str): New username 117 | """ 118 | 119 | self.atconn.request_cloudflare( 120 | f'{ACCOUNT_URL}/username', 121 | 'POST', data={'username': value}, 122 | sendtoken=True, 123 | ) 124 | 125 | def change_email(self, value: str) -> None: 126 | """Changes an e-mail in your Aternos account 127 | 128 | Args: 129 | value (str): New e-mail 130 | 131 | Raises: 132 | ValueError: If an invalid e-mail address 133 | was passed to the function 134 | """ 135 | 136 | if not email_re.match(value): 137 | raise ValueError('Invalid e-mail') 138 | 139 | self.atconn.request_cloudflare( 140 | f'{ACCOUNT_URL}/email', 141 | 'POST', data={'email': value}, 142 | sendtoken=True, 143 | ) 144 | 145 | def change_password(self, old: str, new: str) -> None: 146 | """Changes a password in your Aternos account 147 | 148 | Args: 149 | old (str): Old password 150 | new (str): New password 151 | """ 152 | 153 | self.change_password_hashed( 154 | md5encode(old), 155 | md5encode(new), 156 | ) 157 | 158 | def change_password_hashed(self, old: str, new: str) -> None: 159 | """Changes a password in your Aternos account. 160 | Unlike `change_password`, this function 161 | takes hashed passwords as the arguments 162 | 163 | Args: 164 | old (str): Old password hashed with MD5 165 | new (str): New password hashed with MD5 166 | """ 167 | 168 | self.atconn.request_cloudflare( 169 | f'{ACCOUNT_URL}/password', 170 | 'POST', data={ 171 | 'oldpassword': old, 172 | 'newpassword': new, 173 | }, 174 | sendtoken=True, 175 | ) 176 | 177 | def qrcode_2fa(self) -> Dict[str, str]: 178 | """Requests a secret code and 179 | a QR code for enabling 2FA""" 180 | 181 | return self.atconn.request_cloudflare( 182 | f'{ACCOUNT_URL}/secret', 183 | 'GET', sendtoken=True, 184 | ).json() 185 | 186 | def save_qr(self, qrcode: str, filename: str) -> None: 187 | """Writes a 2FA QR code into a png-file 188 | 189 | Args: 190 | qrcode (str): Base64 encoded png image from `qrcode_2fa()` 191 | filename (str): Where the QR code image must be saved. 192 | Existing file will be rewritten. 193 | """ 194 | 195 | data = qrcode.removeprefix('data:image/png;base64,') 196 | png = base64.b64decode(data) 197 | 198 | with open(filename, 'wb') as f: 199 | f.write(png) 200 | 201 | def enable_2fa(self, code: int) -> None: 202 | """Enables Two-Factor Authentication 203 | 204 | Args: 205 | code (int): 2FA code 206 | """ 207 | 208 | self.atconn.request_cloudflare( 209 | f'{ACCOUNT_URL}/twofactor', 210 | 'POST', data={'code': code}, 211 | sendtoken=True, 212 | ) 213 | 214 | def disable_2fa(self, code: int) -> None: 215 | """Disables Two-Factor Authentication 216 | 217 | Args: 218 | code (int): 2FA code 219 | """ 220 | 221 | self.atconn.request_cloudflare( 222 | f'{ACCOUNT_URL}/disbaleTwofactor', 223 | 'POST', data={'code': code}, 224 | sendtoken=True, 225 | ) 226 | 227 | def logout(self) -> None: 228 | """The same as `atclient.Client.logout`""" 229 | 230 | self.atclient.logout() 231 | -------------------------------------------------------------------------------- /python_aternos/atclient.py: -------------------------------------------------------------------------------- 1 | """Entry point. Authorizes on Aternos 2 | and allows to manage your account""" 3 | 4 | import os 5 | import re 6 | from typing import Optional, Type 7 | 8 | from .atlog import log, is_debug, set_debug 9 | from .atmd5 import md5encode 10 | 11 | from .ataccount import AternosAccount 12 | 13 | from .atconnect import AternosConnect 14 | from .atconnect import AJAX_URL 15 | 16 | from .aterrors import CredentialsError 17 | from .aterrors import TwoFactorAuthError 18 | 19 | from . import atjsparse 20 | from .atjsparse import Interpreter 21 | from .atjsparse import Js2PyInterpreter 22 | 23 | 24 | class Client: 25 | """Aternos API Client class, object 26 | of which contains user's auth data""" 27 | 28 | def __init__(self) -> None: 29 | 30 | # Config 31 | self.sessions_dir = '~' 32 | self.js: Type[Interpreter] = Js2PyInterpreter 33 | # ### 34 | 35 | self.saved_session = '~/.aternos' # will be rewritten by login() 36 | self.atconn = AternosConnect() 37 | self.account = AternosAccount(self) 38 | 39 | def login( 40 | self, 41 | username: str, 42 | password: str, 43 | code: Optional[int] = None) -> None: 44 | """Log in to your Aternos account 45 | with a username and a plain password 46 | 47 | Args: 48 | username (str): Username 49 | password (str): Plain-text password 50 | code (Optional[int], optional): 2FA code 51 | """ 52 | 53 | self.login_hashed( 54 | username, 55 | md5encode(password), 56 | code, 57 | ) 58 | 59 | def login_hashed( 60 | self, 61 | username: str, 62 | md5: str, 63 | code: Optional[int] = None) -> None: 64 | """Log in to your Aternos account 65 | with a username and a hashed password 66 | 67 | Args: 68 | username (str): Username 69 | md5 (str): Password hashed with MD5 70 | code (int): 2FA code 71 | 72 | Raises: 73 | TwoFactorAuthError: If the 2FA is enabled, 74 | but `code` argument was not passed or is incorrect 75 | CredentialsError: If the Aternos backend 76 | returned empty session cookie 77 | (usually because of incorrect credentials) 78 | ValueError: _description_ 79 | """ 80 | 81 | filename = self.session_filename( 82 | username, self.sessions_dir 83 | ) 84 | 85 | try: 86 | self.restore_session(filename) 87 | except (OSError, CredentialsError): 88 | pass 89 | 90 | atjsparse.get_interpreter(create=self.js) 91 | self.atconn.parse_token() 92 | self.atconn.generate_sec() 93 | 94 | credentials = { 95 | 'username': username, 96 | 'password': md5, 97 | } 98 | 99 | if code is not None: 100 | credentials['code'] = str(code) 101 | 102 | loginreq = self.atconn.request_cloudflare( 103 | f'{AJAX_URL}/account/login', 104 | 'POST', data=credentials, sendtoken=True, 105 | ) 106 | 107 | if b'"show2FA":true' in loginreq.content: 108 | raise TwoFactorAuthError('2FA code is required') 109 | 110 | if 'ATERNOS_SESSION' not in loginreq.cookies: 111 | raise CredentialsError( 112 | 'Check your username and password' 113 | ) 114 | 115 | self.saved_session = filename 116 | try: 117 | self.save_session(filename) 118 | except OSError: 119 | pass 120 | 121 | def login_with_session(self, session: str) -> None: 122 | """Log in using ATERNOS_SESSION cookie 123 | 124 | Args: 125 | session (str): Session cookie value 126 | """ 127 | 128 | self.atconn.parse_token() 129 | self.atconn.generate_sec() 130 | self.atconn.session.cookies['ATERNOS_SESSION'] = session 131 | 132 | def logout(self) -> None: 133 | """Log out from the Aternos account""" 134 | 135 | self.atconn.request_cloudflare( 136 | f'{AJAX_URL}/account/logout', 137 | 'GET', sendtoken=True, 138 | ) 139 | 140 | self.remove_session(self.saved_session) 141 | 142 | def restore_session(self, file: str = '~/.aternos') -> None: 143 | """Restores ATERNOS_SESSION cookie and, 144 | if included, servers list, from a session file 145 | 146 | Args: 147 | file (str, optional): Filename 148 | 149 | Raises: 150 | FileNotFoundError: If the file cannot be found 151 | CredentialsError: If the session cookie 152 | (or the file at all) has incorrect format 153 | """ 154 | 155 | file = os.path.expanduser(file) 156 | log.debug('Restoring session from %s', file) 157 | 158 | if not os.path.exists(file): 159 | raise FileNotFoundError() 160 | 161 | with open(file, 'rt', encoding='utf-8') as f: 162 | saved = f.read() \ 163 | .strip() \ 164 | .replace('\r\n', '\n') \ 165 | .split('\n') 166 | 167 | session = saved[0].strip() 168 | if session == '' or not session.isalnum(): 169 | raise CredentialsError( 170 | 'Session cookie is invalid or the file is empty' 171 | ) 172 | 173 | if len(saved) > 1: 174 | self.account.refresh_servers(saved[1:]) 175 | 176 | self.atconn.session.cookies['ATERNOS_SESSION'] = session 177 | self.saved_session = file 178 | 179 | def save_session( 180 | self, 181 | file: str = '~/.aternos', 182 | incl_servers: bool = True) -> None: 183 | """Saves an ATERNOS_SESSION cookie to a file 184 | 185 | Args: 186 | file (str, optional): File where a session cookie must be saved 187 | incl_servers (bool, optional): If the function 188 | should include the servers IDs in this file 189 | to reduce API requests count on the next restoration 190 | (recommended) 191 | """ 192 | 193 | file = os.path.expanduser(file) 194 | log.debug('Saving session to %s', file) 195 | 196 | with open(file, 'wt', encoding='utf-8') as f: 197 | 198 | f.write(self.atconn.atsession + '\n') 199 | if not incl_servers: 200 | return 201 | 202 | for s in self.account.servers: 203 | f.write(s.servid + '\n') 204 | 205 | def remove_session(self, file: str = '~/.aternos') -> None: 206 | """Removes a file which contains 207 | ATERNOS_SESSION cookie saved 208 | with `save_session()` 209 | 210 | Args: 211 | file (str, optional): Filename 212 | """ 213 | 214 | file = os.path.expanduser(file) 215 | log.debug('Removing session file: %s', file) 216 | 217 | try: 218 | os.remove(file) 219 | except OSError as err: 220 | log.warning('Unable to delete session file: %s', err) 221 | 222 | @staticmethod 223 | def session_filename(username: str, sessions_dir: str = '~') -> str: 224 | """Generates a session file name 225 | 226 | Args: 227 | username (str): Authenticated user 228 | sessions_dir (str, optional): Path to directory 229 | with automatically saved sessions 230 | 231 | Returns: 232 | Filename 233 | """ 234 | 235 | # unsafe symbols replacement 236 | repl = '_' 237 | 238 | secure = re.sub( 239 | r'[^A-Za-z0-9_-]', 240 | repl, username, 241 | ) 242 | 243 | return f'{sessions_dir}/.at_{secure}' 244 | 245 | @property 246 | def debug(self) -> bool: 247 | return is_debug() 248 | 249 | @debug.setter 250 | def debug(self, state: bool) -> None: 251 | return set_debug(state) 252 | -------------------------------------------------------------------------------- /python_aternos/atconf.py: -------------------------------------------------------------------------------- 1 | """Modifying server and world options""" 2 | 3 | # TODO: Still needs refactoring 4 | 5 | import enum 6 | import re 7 | 8 | from typing import Any, Dict, List, Union, Optional 9 | from typing import TYPE_CHECKING 10 | 11 | import lxml.html 12 | 13 | from .atconnect import BASE_URL, AJAX_URL 14 | if TYPE_CHECKING: 15 | from .atserver import AternosServer 16 | 17 | 18 | DAT_PREFIX = 'Data:' 19 | DAT_GR_PREFIX = 'Data:GameRules:' 20 | 21 | 22 | class ServerOpts(enum.Enum): 23 | 24 | """server.options file""" 25 | 26 | players = 'max-players' 27 | gm = 'gamemode' 28 | difficulty = 'difficulty' 29 | whl = 'white-list' 30 | online = 'online-mode' 31 | pvp = 'pvp' 32 | cmdblock = 'enable-command-block' 33 | flight = 'allow-flight' 34 | animals = 'spawn-animals' 35 | monsters = 'spawn-monsters' 36 | villagers = 'spawn-npcs' 37 | nether = 'allow-nether' 38 | forcegm = 'force-gamemode' 39 | spawnlock = 'spawn-protection' 40 | cmds = 'allow-cheats' 41 | packreq = 'require-resource-pack' 42 | pack = 'resource-pack' 43 | 44 | 45 | class WorldOpts(enum.Enum): 46 | 47 | """level.dat file""" 48 | 49 | seed12 = 'randomseed' 50 | seed = 'seed' 51 | hardcore = 'hardcore' 52 | difficulty = 'Difficulty' 53 | 54 | 55 | class WorldRules(enum.Enum): 56 | 57 | """/gamerule list""" 58 | 59 | advs = 'announceAdvancements' 60 | univanger = 'universalAnger' 61 | cmdout = 'commandBlockOutput' 62 | elytra = 'disableElytraMovementCheck' 63 | raids = 'disableRaids' 64 | daynight = 'doDaylightCycle' 65 | entdrop = 'doEntityDrops' 66 | fire = 'doFireTick' 67 | phantoms = 'doInsomnia' 68 | immrespawn = 'doImmediateRespawn' 69 | limitcraft = 'doLimitedCrafting' 70 | mobloot = 'doMobLoot' 71 | mobs = 'doMobSpawning' 72 | patrols = 'doPatrolSpawning' 73 | blockdrop = 'doTileDrops' 74 | traders = 'doTraderSpawning' 75 | weather = 'doWeatherCycle' 76 | drowndmg = 'drowningDamage' 77 | falldmg = 'fallDamage' 78 | firedmg = 'fireDamage' 79 | snowdmg = 'freezeDamage' 80 | forgive = 'forgiveDeadPlayers' 81 | keepinv = 'keepInventory' 82 | deathmsg = 'showDeathMessages' 83 | admincmdlog = 'logAdminCommands' 84 | cmdlen = 'maxCommandChainLength' 85 | entcram = 'maxEntityCramming' 86 | mobgrief = 'mobGriefing' 87 | regen = 'naturalRegeneration' 88 | sleeppct = 'playersSleepingPercentage' 89 | rndtick = 'randomTickSpeed' 90 | spawnradius = 'spawnRadius' 91 | reducedf3 = 'reducedDebugInfo' 92 | spectchunkgen = 'spectatorsGenerateChunks' 93 | cmdfb = 'sendCommandFeedback' 94 | 95 | 96 | class Gamemode(enum.IntEnum): 97 | 98 | """/gamemode numeric list""" 99 | 100 | survival = 0 101 | creative = 1 102 | adventure = 2 103 | spectator = 3 104 | 105 | 106 | class Difficulty(enum.IntEnum): 107 | 108 | """/difficulty numeric list""" 109 | 110 | peaceful = 0 111 | easy = 1 112 | normal = 2 113 | hard = 3 114 | 115 | 116 | # checking timezone format 117 | tzcheck = re.compile(r'(^[A-Z]\w+\/[A-Z]\w+$)|^UTC$') 118 | 119 | # options types converting 120 | convert = { 121 | 'config-option-number': int, 122 | 'config-option-select': int, 123 | 'config-option-toggle': bool, 124 | } 125 | 126 | 127 | class AternosConfig: 128 | 129 | """Class for editing server settings""" 130 | 131 | def __init__(self, atserv: 'AternosServer') -> None: 132 | """Class for editing server settings 133 | 134 | Args: 135 | atserv (python_aternos.atserver.AternosServer): 136 | atserver.AternosServer object 137 | """ 138 | 139 | self.atserv = atserv 140 | 141 | def get_timezone(self) -> str: 142 | """Parses timezone from options page 143 | 144 | Returns: 145 | Area/Location 146 | """ 147 | 148 | optreq = self.atserv.atserver_request( 149 | f'{BASE_URL}/options', 'GET' 150 | ) 151 | opttree = lxml.html.fromstring(optreq) 152 | 153 | tzopt = opttree.xpath( 154 | '//div[@class="options-other-input timezone-switch"]' 155 | )[0] 156 | tztext = tzopt.xpath('.//div[@class="option current"]')[0].text 157 | return tztext.strip() 158 | 159 | def set_timezone(self, value: str) -> None: 160 | """Sets new timezone 161 | 162 | Args: 163 | value (str): New timezone 164 | 165 | Raises: 166 | ValueError: If given string doesn't 167 | match `Area/Location` format 168 | """ 169 | 170 | matches_tz = tzcheck.search(value) 171 | if not matches_tz: 172 | raise ValueError( 173 | 'Timezone must match zoneinfo format: Area/Location' 174 | ) 175 | 176 | self.atserv.atserver_request( 177 | f'{AJAX_URL}/timezone.php', 178 | 'POST', data={'timezone': value}, 179 | sendtoken=True 180 | ) 181 | 182 | def get_java(self) -> int: 183 | """Parses Java version from options page 184 | 185 | Returns: 186 | Java image version 187 | """ 188 | 189 | optreq = self.atserv.atserver_request( 190 | f'{BASE_URL}/options', 'GET' 191 | ) 192 | opttree = lxml.html.fromstring(optreq) 193 | imgopt = opttree.xpath( 194 | '//div[@class="options-other-input image-switch"]' 195 | )[0] 196 | imgver = imgopt.xpath( 197 | './/div[@class="option current"]/@data-value' 198 | )[0] 199 | 200 | jdkver = str(imgver or '').removeprefix('openjdk:') 201 | return int(jdkver) 202 | 203 | def set_java(self, value: int) -> None: 204 | """Sets new Java version 205 | 206 | Args: 207 | value (int): New Java image version 208 | """ 209 | 210 | self.atserv.atserver_request( 211 | f'{AJAX_URL}/image.php', 212 | 'POST', data={'image': f'openjdk:{value}'}, 213 | sendtoken=True 214 | ) 215 | 216 | # 217 | # server.properties 218 | # 219 | def set_server_prop(self, option: str, value: Any) -> None: 220 | """Sets server.properties option 221 | 222 | Args: 223 | option (str): Option name 224 | value (Any): New value 225 | """ 226 | 227 | self.__set_prop( 228 | '/server.properties', 229 | option, value 230 | ) 231 | 232 | def get_server_props(self, proptyping: bool = True) -> Dict[str, Any]: 233 | """Parses all server.properties from options page 234 | 235 | Args: 236 | proptyping (bool, optional): 237 | If the returned dict should 238 | contain value that matches 239 | property type (e.g. max-players will be int) 240 | instead of string 241 | 242 | Returns: 243 | `server.properties` dictionary 244 | """ 245 | 246 | return self.__get_all_props(f'{BASE_URL}/options', proptyping) 247 | 248 | def set_server_props(self, props: Dict[str, Any]) -> None: 249 | """Updates server.properties options with the given dict 250 | 251 | Args: 252 | props (Dict[str,Any]): 253 | Dictionary with `{key:value}` properties 254 | """ 255 | 256 | for key in props: 257 | self.set_server_prop(key, props[key]) 258 | 259 | # 260 | # level.dat 261 | # 262 | def set_world_prop( 263 | self, option: Union[WorldOpts, WorldRules], 264 | value: Any, gamerule: bool = False, 265 | world: str = 'world') -> None: 266 | """Sets level.dat option for specified world 267 | 268 | Args: 269 | option (Union[WorldOpts, WorldRules]): Option name 270 | value (Any): New value 271 | gamerule (bool, optional): If the option is a gamerule 272 | world (str, optional): Name of the world which 273 | `level.dat` must be edited 274 | """ 275 | 276 | prefix = DAT_PREFIX 277 | if gamerule: 278 | prefix = DAT_GR_PREFIX 279 | 280 | self.__set_prop( 281 | f'/{world}/level.dat', 282 | f'{prefix}{option}', 283 | value 284 | ) 285 | 286 | def get_world_props( 287 | self, world: str = 'world', 288 | proptyping: bool = True) -> Dict[str, Any]: 289 | """Parses level.dat from specified world's options page 290 | 291 | Args: 292 | world (str, optional): Name of the worl 293 | proptyping (bool, optional): 294 | If the returned dict should 295 | contain the value that matches 296 | property type (e.g. randomTickSpeed will be bool) 297 | instead of string 298 | 299 | Returns: 300 | `level.dat` options dictionary 301 | """ 302 | 303 | return self.__get_all_props( 304 | f'{BASE_URL}/files/{world}/level.dat', 305 | proptyping, [DAT_PREFIX, DAT_GR_PREFIX] 306 | ) 307 | 308 | def set_world_props( 309 | self, 310 | props: Dict[Union[WorldOpts, WorldRules], Any], 311 | world: str = 'world') -> None: 312 | """Sets level.dat options from 313 | the dictionary for the specified world 314 | 315 | Args: 316 | props (Dict[Union[WorldOpts, WorldRules], Any]): 317 | `level.dat` options 318 | world (str): name of the world which 319 | `level.dat` must be edited 320 | """ 321 | 322 | for key in props: 323 | self.set_world_prop( 324 | option=key, 325 | value=props[key], 326 | world=world 327 | ) 328 | 329 | # 330 | # helpers 331 | # 332 | def __set_prop(self, file: str, option: str, value: Any) -> None: 333 | 334 | self.atserv.atserver_request( 335 | f'{AJAX_URL}/config.php', 336 | 'POST', data={ 337 | 'file': file, 338 | 'option': option, 339 | 'value': value 340 | }, sendtoken=True 341 | ) 342 | 343 | def __get_all_props( 344 | self, url: str, proptyping: bool = True, 345 | prefixes: Optional[List[str]] = None) -> Dict[str, Any]: 346 | 347 | optreq = self.atserv.atserver_request(url, 'GET') 348 | opttree = lxml.html.fromstring(optreq.content) 349 | configs = opttree.xpath('//div[@class="config-options"]') 350 | 351 | for i, conf in enumerate(configs): 352 | opts = conf.xpath('/div[contains(@class,"config-option ")]') 353 | result = {} 354 | 355 | for opt in opts: 356 | key = opt.xpath( 357 | './/span[@class="config-option-output-key"]' 358 | )[0].text 359 | value = opt.xpath( 360 | './/span[@class="config-option-output-value"]' 361 | )[0].text 362 | 363 | if prefixes is not None: 364 | key = f'{prefixes[i]}{key}' 365 | 366 | opttype = opt.xpath('/@class').split(' ')[1] 367 | if proptyping and opttype in convert: 368 | value = convert[opttype](value) 369 | 370 | result[key] = value 371 | 372 | return result 373 | -------------------------------------------------------------------------------- /python_aternos/atconnect.py: -------------------------------------------------------------------------------- 1 | """Stores API session and sends requests""" 2 | 3 | import re 4 | import time 5 | 6 | import string 7 | import secrets 8 | 9 | from functools import partial 10 | 11 | from typing import Optional 12 | from typing import List, Dict, Any 13 | 14 | import requests 15 | 16 | from cloudscraper import CloudScraper 17 | 18 | from .atlog import log, is_debug 19 | 20 | from . import atjsparse 21 | from .aterrors import TokenError 22 | from .aterrors import CloudflareError 23 | from .aterrors import AternosPermissionError 24 | 25 | 26 | BASE_URL = 'https://aternos.org' 27 | AJAX_URL = f'{BASE_URL}/ajax' 28 | 29 | REQUA = \ 30 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \ 31 | '(KHTML, like Gecko) Chrome/99.0.4844.84 Safari/537.36 OPR/85.0.4341.47' 32 | 33 | ARROW_FN_REGEX = r'\(\(\).*?\)\(\);' 34 | SCRIPT_TAG_REGEX = ( 35 | rb'' 36 | ) 37 | 38 | SEC_ALPHABET = string.ascii_lowercase + string.digits 39 | 40 | 41 | class AternosConnect: 42 | """Class for sending API requests, 43 | bypassing Cloudflare and parsing responses""" 44 | 45 | def __init__(self) -> None: 46 | 47 | self.session = CloudScraper() 48 | self.sec = '' 49 | self.token = '' 50 | self.atcookie = '' 51 | 52 | def refresh_session(self) -> None: 53 | """Creates a new CloudScraper 54 | session object and copies all cookies. 55 | Required for bypassing Cloudflare""" 56 | 57 | old_cookies = self.session.cookies 58 | captcha_kwarg = self.session.captcha 59 | self.session = CloudScraper(captcha=captcha_kwarg) 60 | self.session.cookies.update(old_cookies) 61 | del old_cookies 62 | 63 | def parse_token(self) -> str: 64 | """Parses Aternos ajax token that 65 | is needed for most requests 66 | 67 | Raises: 68 | TokenError: If the parser is unable 69 | to extract ajax token from HTML 70 | 71 | Returns: 72 | Aternos ajax token 73 | """ 74 | 75 | loginpage = self.request_cloudflare( 76 | f'{BASE_URL}/go/', 'GET' 77 | ).content 78 | 79 | # Using the standard string methods 80 | # instead of the expensive xml parsing 81 | head = b'' 82 | headtag = loginpage.find(head) 83 | headend = loginpage.find(b'', headtag + len(head)) 84 | 85 | # Some checks 86 | if headtag < 0 or headend < 0: 87 | pagehead = loginpage 88 | log.warning( 89 | 'Unable to find tag, parsing the whole page' 90 | ) 91 | 92 | else: 93 | # Extracting content 94 | headtag = headtag + len(head) 95 | pagehead = loginpage[headtag:headend] 96 | 97 | js_code: Optional[List[Any]] = None 98 | 99 | try: 100 | text = pagehead.decode('utf-8', 'replace') 101 | js_code = re.findall(ARROW_FN_REGEX, text) 102 | 103 | token_func = js_code[0] 104 | if len(js_code) > 1: 105 | token_func = js_code[1] 106 | 107 | js = atjsparse.get_interpreter() 108 | js.exec_js(token_func) 109 | self.token = js['AJAX_TOKEN'] 110 | 111 | except (IndexError, TypeError) as err: 112 | 113 | log.warning('---') 114 | log.warning('Unable to parse AJAX_TOKEN!') 115 | log.warning('Please, insert the info below') 116 | log.warning('to the GitHub issue description:') 117 | log.warning('---') 118 | 119 | log.warning('JavaScript: %s', js_code) 120 | log.warning( 121 | 'All script tags: %s', 122 | re.findall(SCRIPT_TAG_REGEX, pagehead) 123 | ) 124 | log.warning('---') 125 | 126 | raise TokenError( 127 | 'Unable to parse TOKEN from the page' 128 | ) from err 129 | 130 | return self.token 131 | 132 | def generate_sec(self) -> str: 133 | """Generates Aternos SEC token which 134 | is also needed for most API requests 135 | 136 | Returns: 137 | Random SEC `key:value` string 138 | """ 139 | 140 | randkey = self.generate_sec_part() 141 | randval = self.generate_sec_part() 142 | self.sec = f'{randkey}:{randval}' 143 | self.session.cookies.set( 144 | f'ATERNOS_SEC_{randkey}', randval, 145 | domain='aternos.org' 146 | ) 147 | 148 | return self.sec 149 | 150 | def generate_sec_part(self) -> str: 151 | """Generates a part for SEC token""" 152 | 153 | return ''.join( 154 | secrets.choice(SEC_ALPHABET) 155 | for _ in range(11) 156 | ) + ('0' * 5) 157 | 158 | def request_cloudflare( 159 | self, url: str, method: str, 160 | params: Optional[Dict[Any, Any]] = None, 161 | data: Optional[Dict[Any, Any]] = None, 162 | headers: Optional[Dict[Any, Any]] = None, 163 | reqcookies: Optional[Dict[Any, Any]] = None, 164 | sendtoken: bool = False, 165 | retries: int = 5, 166 | timeout: int = 4) -> requests.Response: 167 | """Sends a request to Aternos API bypass Cloudflare 168 | 169 | Args: 170 | url (str): Request URL 171 | method (str): Request method, must be GET or POST 172 | params (Optional[Dict[Any, Any]], optional): URL parameters 173 | data (Optional[Dict[Any, Any]], optional): POST request data, 174 | if the method is GET, this dict will be combined with params 175 | headers (Optional[Dict[Any, Any]], optional): Custom headers 176 | reqcookies (Optional[Dict[Any, Any]], optional): 177 | Cookies only for this request 178 | sendtoken (bool, optional): If the ajax and SEC token 179 | should be sent 180 | retries (int, optional): How many times parser must retry 181 | connection to API bypass Cloudflare 182 | timeout (int, optional): Request timeout in seconds 183 | 184 | Raises: 185 | CloudflareError: When the parser has exceeded retries count 186 | NotImplementedError: When the specified method is not GET or POST 187 | 188 | Returns: 189 | API response 190 | """ 191 | 192 | if retries <= 0: 193 | raise CloudflareError('Unable to bypass Cloudflare protection') 194 | 195 | try: 196 | self.atcookie = self.session.cookies['ATERNOS_SESSION'] 197 | except KeyError: 198 | pass 199 | 200 | self.refresh_session() 201 | 202 | params = params or {} 203 | data = data or {} 204 | headers = headers or {} 205 | reqcookies = reqcookies or {} 206 | 207 | method = method or 'GET' 208 | method = method.upper().strip() 209 | if method not in ('GET', 'POST'): 210 | raise NotImplementedError('Only GET and POST are available') 211 | 212 | if sendtoken: 213 | params['TOKEN'] = self.token 214 | params['SEC'] = self.sec 215 | headers['X-Requested-With'] = 'XMLHttpRequest' 216 | 217 | # requests.cookies.CookieConflictError bugfix 218 | reqcookies['ATERNOS_SESSION'] = self.atcookie 219 | del self.session.cookies['ATERNOS_SESSION'] 220 | 221 | if is_debug(): 222 | 223 | reqcookies_dbg = { 224 | k: str(v or '')[:3] 225 | for k, v in reqcookies.items() 226 | } 227 | 228 | session_cookies_dbg = { 229 | k: str(v or '')[:3] 230 | for k, v in self.session.cookies.items() 231 | } 232 | 233 | log.debug('Requesting(%s)%s', method, url) 234 | log.debug('headers=%s', headers) 235 | log.debug('params=%s', params) 236 | log.debug('data=%s', data) 237 | log.debug('req-cookies=%s', reqcookies_dbg) 238 | log.debug('session-cookies=%s', session_cookies_dbg) 239 | 240 | if method == 'POST': 241 | sendreq = partial( 242 | self.session.post, 243 | params=params, 244 | data=data, 245 | ) 246 | else: 247 | sendreq = partial( 248 | self.session.get, 249 | params={**params, **data}, 250 | ) 251 | 252 | req = sendreq( 253 | url, 254 | headers=headers, 255 | cookies=reqcookies, 256 | timeout=timeout, 257 | ) 258 | 259 | resp_type = req.headers.get('content-type', '') 260 | html_type = resp_type.find('text/html') != -1 261 | cloudflare = req.status_code == 403 262 | 263 | if html_type and cloudflare: 264 | log.info('Retrying to bypass Cloudflare') 265 | time.sleep(0.3) 266 | return self.request_cloudflare( 267 | url, method, 268 | params, data, 269 | headers, reqcookies, 270 | sendtoken, retries - 1 271 | ) 272 | 273 | log.debug('AternosConnect received: %s', req.text[:65]) 274 | log.info( 275 | '%s completed with %s status', 276 | method, req.status_code 277 | ) 278 | 279 | if req.status_code == 402: 280 | raise AternosPermissionError 281 | 282 | req.raise_for_status() 283 | return req 284 | 285 | @property 286 | def atsession(self) -> str: 287 | """Aternos session cookie, 288 | empty string if not logged in 289 | 290 | Returns: 291 | Session cookie 292 | """ 293 | 294 | return self.session.cookies.get( 295 | 'ATERNOS_SESSION', '' 296 | ) 297 | -------------------------------------------------------------------------------- /python_aternos/aterrors.py: -------------------------------------------------------------------------------- 1 | """Exception classes""" 2 | 3 | from typing import Final 4 | 5 | 6 | class AternosError(Exception): 7 | 8 | """Common error class""" 9 | 10 | 11 | class CloudflareError(AternosError): 12 | 13 | """Raised when the parser is unable 14 | to bypass Cloudflare protection""" 15 | 16 | 17 | class CredentialsError(AternosError): 18 | 19 | """Raised when a session cookie is empty 20 | which means incorrect credentials""" 21 | 22 | 23 | class TwoFactorAuthError(CredentialsError): 24 | 25 | """Raised if 2FA is enabled, 26 | but code was not passed to a login function""" 27 | 28 | 29 | class TokenError(AternosError): 30 | 31 | """Raised when the parser is unable 32 | to extract Aternos ajax token""" 33 | 34 | 35 | class ServerError(AternosError): 36 | 37 | """Common class for server errors""" 38 | 39 | def __init__(self, reason: str, message: str = '') -> None: 40 | """Common class for server errors 41 | 42 | Args: 43 | reason (str): Code which contains error reason 44 | message (str, optional): Error message 45 | """ 46 | 47 | self.reason = reason 48 | super().__init__(message) 49 | 50 | 51 | class ServerStartError(AternosError): 52 | 53 | """Raised when Aternos can not start Minecraft server""" 54 | 55 | MESSAGE: Final = 'Unable to start server, code: {}' 56 | reason_msg = { 57 | 58 | 'eula': 59 | 'EULA was not accepted. ' 60 | 'Use start(accepteula=True)', 61 | 62 | 'already': 'Server has already started', 63 | 'wrongversion': 'Incorrect software version installed', 64 | 65 | 'file': 66 | 'File server is unavailbale, ' 67 | 'view https://status.aternos.gmbh', 68 | 69 | 'size': 'Available storage size limit (4 GB) has been reached' 70 | } 71 | 72 | def __init__(self, reason: str) -> None: 73 | """Raised when Aternos 74 | can not start Minecraft server 75 | 76 | Args: 77 | reason (str): 78 | Code which contains error reason 79 | """ 80 | 81 | super().__init__( 82 | reason, 83 | self.reason_msg.get( 84 | reason, 85 | self.MESSAGE.format(reason) 86 | ) 87 | ) 88 | 89 | 90 | class FileError(AternosError): 91 | 92 | """Raised when trying to execute a disallowed 93 | by Aternos file operation""" 94 | 95 | 96 | # PermissionError is a built-in, 97 | # so this exception called AternosPermissionError 98 | class AternosPermissionError(AternosError): 99 | 100 | """Raised when trying to execute a disallowed command, 101 | usually because of shared access rights""" 102 | -------------------------------------------------------------------------------- /python_aternos/atfile.py: -------------------------------------------------------------------------------- 1 | """File info object used by `python_aternos.atfm`""" 2 | 3 | import enum 4 | 5 | from typing import Union 6 | from typing import TYPE_CHECKING 7 | 8 | import lxml.html 9 | 10 | from .atconnect import BASE_URL, AJAX_URL 11 | from .aterrors import FileError 12 | 13 | if TYPE_CHECKING: 14 | from .atserver import AternosServer 15 | 16 | 17 | class FileType(enum.IntEnum): 18 | 19 | """File or dierctory""" 20 | 21 | file = 0 22 | directory = 1 23 | dir = 1 24 | 25 | 26 | class AternosFile: 27 | 28 | """File class which contains info 29 | about its path, type and size""" 30 | 31 | def __init__( 32 | self, 33 | atserv: 'AternosServer', 34 | path: str, rmable: bool, 35 | dlable: bool, editable: bool, 36 | ftype: FileType = FileType.file, 37 | size: Union[int, float] = 0) -> None: 38 | """File class which contains info 39 | about its path, type and size 40 | 41 | Args: 42 | atserv (python_aternos.atserver.AternosServer): 43 | atserver.AternosServer instance 44 | path (str): Absolute path to the file 45 | rmable (bool): Is the file deleteable (removeable) 46 | dlable (bool): Is the file downloadable 47 | ftype (python_aternos.atfile.FileType): File or directory 48 | size (Union[int,float], optional): File size 49 | """ 50 | 51 | path = path.lstrip('/') 52 | path = '/' + path 53 | 54 | self.atserv = atserv 55 | 56 | self._path = path 57 | self._name = path[path.rfind('/') + 1:] 58 | self._dirname = path[:path.rfind('/')] 59 | 60 | self._deleteable = rmable 61 | self._downloadable = dlable 62 | self._editable = editable 63 | 64 | self._ftype = ftype 65 | self._size = float(size) 66 | 67 | def create( 68 | self, 69 | name: str, 70 | ftype: FileType = FileType.file) -> None: 71 | """Creates a file or a directory inside this one 72 | 73 | Args: 74 | name (str): Filename 75 | ftype (FileType, optional): File type 76 | 77 | Raises: 78 | RuntimeWarning: Messages about probabilty of FileError 79 | (if `self` file object is not a directory) 80 | FileError: If Aternos denied file creation 81 | """ 82 | 83 | if self.is_file: 84 | raise RuntimeWarning( 85 | 'Creating files only available ' 86 | 'inside directories' 87 | ) 88 | 89 | name = name.strip().replace('/', '_') 90 | req = self.atserv.atserver_request( 91 | f'{AJAX_URL}/files/create.php', 92 | 'POST', data={ 93 | 'file': f'{self._path}/{name}', 94 | 'type': 'file' 95 | if ftype == FileType.file 96 | else 'directory' 97 | } 98 | ) 99 | 100 | if req.content == b'{"success":false}': 101 | raise FileError('Unable to create a file') 102 | 103 | def delete(self) -> None: 104 | """Deletes the file 105 | 106 | Raises: 107 | RuntimeWarning: Message about probability of FileError 108 | FileError: If deleting this file is disallowed by Aternos 109 | """ 110 | 111 | if not self._deleteable: 112 | raise RuntimeWarning( 113 | 'The file seems to be protected (undeleteable). ' 114 | 'Always check it before calling delete()' 115 | ) 116 | 117 | req = self.atserv.atserver_request( 118 | f'{AJAX_URL}/delete.php', 119 | 'POST', data={'file': self._path}, 120 | sendtoken=True 121 | ) 122 | 123 | if req.content == b'{"success":false}': 124 | raise FileError('Unable to delete the file') 125 | 126 | def get_content(self) -> bytes: 127 | """Requests file content in bytes (downloads it) 128 | 129 | Raises: 130 | RuntimeWarning: Message about probability of FileError 131 | FileError: If downloading this file is disallowed by Aternos 132 | 133 | Returns: 134 | File content 135 | """ 136 | 137 | if not self._downloadable: 138 | raise RuntimeWarning( 139 | 'The file seems to be undownloadable. ' 140 | 'Always check it before calling get_content()' 141 | ) 142 | 143 | file = self.atserv.atserver_request( 144 | f'{AJAX_URL}/files/download.php', 145 | 'GET', params={ 146 | 'file': self._path 147 | } 148 | ) 149 | 150 | if file.content == b'{"success":false}': 151 | raise FileError( 152 | 'Unable to download the file. ' 153 | 'Try to get text' 154 | ) 155 | 156 | return file.content 157 | 158 | def set_content(self, value: bytes) -> None: 159 | """Modifies file content 160 | 161 | Args: 162 | value (bytes): New content 163 | 164 | Raises: 165 | FileError: If Aternos denied file saving 166 | """ 167 | 168 | req = self.atserv.atserver_request( 169 | f'{AJAX_URL}/save.php', 170 | 'POST', data={ 171 | 'file': self._path, 172 | 'content': value 173 | }, sendtoken=True 174 | ) 175 | 176 | if req.content == b'{"success":false}': 177 | raise FileError('Unable to save the file') 178 | 179 | def get_text(self) -> str: 180 | """Requests editing the file as a text 181 | 182 | Raises: 183 | RuntimeWarning: Message about probability of FileError 184 | FileError: If unable to parse text from response 185 | 186 | Returns: 187 | File text content 188 | """ 189 | 190 | if not self._editable: 191 | raise RuntimeWarning( 192 | 'The file seems to be uneditable. ' 193 | 'Always check it before calling get_text()' 194 | ) 195 | 196 | if self.is_dir: 197 | raise RuntimeWarning( 198 | 'Use get_content() to download ' 199 | 'a directory as a ZIP file!' 200 | ) 201 | 202 | filepath = self._path.lstrip("/") 203 | editor = self.atserv.atserver_request( 204 | f'{BASE_URL}/files/{filepath}', 'GET' 205 | ) 206 | edittree = lxml.html.fromstring(editor.content) 207 | editblock = edittree.xpath('//div[@id="editor"]') 208 | 209 | if len(editblock) < 1: 210 | raise FileError( 211 | 'Unable to open editor. ' 212 | 'Try to get file content' 213 | ) 214 | 215 | return editblock[0].text_content() 216 | 217 | def set_text(self, value: str) -> None: 218 | """Modifies the file content, 219 | but unlike `set_content` takes 220 | a string as an argument 221 | 222 | Args: 223 | value (str): New content 224 | """ 225 | 226 | self.set_content(value.encode('utf-8')) 227 | 228 | @property 229 | def path(self) -> str: 230 | """Abslute path to the file 231 | without leading slash 232 | including filename 233 | 234 | Returns: 235 | Full path to the file 236 | """ 237 | 238 | return self._path 239 | 240 | @property 241 | def name(self) -> str: 242 | """Filename with extension 243 | 244 | Returns: 245 | Filename 246 | """ 247 | 248 | return self._name 249 | 250 | @property 251 | def dirname(self) -> str: 252 | """Full path to the directory 253 | which contains the file 254 | without leading slash. 255 | Empty path means root (`/`) 256 | 257 | Returns: 258 | Path to the directory 259 | """ 260 | 261 | return self._dirname 262 | 263 | @property 264 | def deleteable(self) -> bool: 265 | """True if the file can be deleted, 266 | otherwise False 267 | 268 | Returns: 269 | Can the file be deleted 270 | """ 271 | 272 | return self._deleteable 273 | 274 | @property 275 | def downloadable(self) -> bool: 276 | """True if the file can be downloaded, 277 | otherwise False 278 | 279 | Returns: 280 | Can the file be downloaded 281 | """ 282 | 283 | return self._downloadable 284 | 285 | @property 286 | def editable(self) -> bool: 287 | """True if the file can be 288 | opened in Aternos editor, 289 | otherwise False 290 | 291 | Returns: 292 | Can the file be edited 293 | """ 294 | 295 | return self._editable 296 | 297 | @property 298 | def ftype(self) -> FileType: 299 | """File object type: file or directory 300 | 301 | Returns: 302 | File type 303 | """ 304 | 305 | return self._ftype 306 | 307 | @property 308 | def is_dir(self) -> bool: 309 | """Check if the file object is a directory 310 | 311 | Returns: 312 | True if it is a directory, otherwise False 313 | """ 314 | 315 | return self._ftype == FileType.dir 316 | 317 | @property 318 | def is_file(self) -> bool: 319 | """Check if the file object is not a directory 320 | 321 | Returns: 322 | True if it is a file, otherwise False 323 | """ 324 | 325 | return self._ftype == FileType.file 326 | 327 | @property 328 | def size(self) -> float: 329 | """File size in bytes 330 | 331 | Returns: 332 | File size 333 | """ 334 | 335 | return self._size 336 | -------------------------------------------------------------------------------- /python_aternos/atfm.py: -------------------------------------------------------------------------------- 1 | """Exploring files in your server directory""" 2 | 3 | from typing import Union, Optional, Any, List 4 | from typing import TYPE_CHECKING 5 | 6 | import lxml.html 7 | 8 | from .atconnect import BASE_URL, AJAX_URL 9 | from .atfile import AternosFile, FileType 10 | 11 | if TYPE_CHECKING: 12 | from .atserver import AternosServer 13 | 14 | 15 | class FileManager: 16 | 17 | """Aternos file manager class 18 | for viewing files structure""" 19 | 20 | def __init__(self, atserv: 'AternosServer') -> None: 21 | """Aternos file manager class 22 | for viewing files structure 23 | 24 | Args: 25 | atserv (python_aternos.atserver.AternosServer): 26 | atserver.AternosServer instance 27 | """ 28 | 29 | self.atserv = atserv 30 | 31 | def list_dir(self, path: str = '') -> List[AternosFile]: 32 | """Requests a list of files 33 | in the specified directory 34 | 35 | Args: 36 | path (str, optional): 37 | Directory (an empty string means root) 38 | 39 | Returns: 40 | List of atfile.AternosFile objects 41 | """ 42 | 43 | path = path.lstrip('/') 44 | 45 | filesreq = self.atserv.atserver_request( 46 | f'{BASE_URL}/files/{path}', 'GET' 47 | ) 48 | filestree = lxml.html.fromstring(filesreq.content) 49 | 50 | fileslist = filestree.xpath( 51 | '//div[@class="file" or @class="file clickable"]' 52 | ) 53 | 54 | files = [] 55 | for f in fileslist: 56 | 57 | ftype_raw = f.xpath('@data-type')[0] 58 | fsize = self.extract_size( 59 | f.xpath('./div[@class="filesize"]') 60 | ) 61 | 62 | rm_btn = f.xpath('./div[contains(@class,"js-delete-file")]') 63 | dl_btn = f.xpath('./div[contains(@class,"js-download-file")]') 64 | clickable = 'clickable' in f.classes 65 | is_config = ('server.properties' in path) or ('level.dat' in path) 66 | 67 | files.append( 68 | AternosFile( 69 | atserv=self.atserv, 70 | path=f.xpath('@data-path')[0], 71 | 72 | rmable=(len(rm_btn) > 0), 73 | dlable=(len(dl_btn) > 0), 74 | editable=(clickable and not is_config), 75 | 76 | ftype={'file': FileType.file}.get( 77 | ftype_raw, FileType.dir 78 | ), 79 | size=fsize 80 | ) 81 | ) 82 | 83 | return files 84 | 85 | def extract_size(self, fsize_raw: List[Any]) -> float: 86 | """Parses file size from the LXML tree 87 | 88 | Args: 89 | fsize_raw (List[Any]): XPath parsing result 90 | 91 | Returns: 92 | File size in bytes 93 | """ 94 | 95 | if len(fsize_raw) > 0: 96 | 97 | fsize_text = fsize_raw[0].text.strip() 98 | fsize_num = fsize_text[:fsize_text.rfind(' ')] 99 | fsize_msr = fsize_text[fsize_text.rfind(' ') + 1:] 100 | 101 | try: 102 | return self.convert_size( 103 | float(fsize_num), 104 | fsize_msr 105 | ) 106 | except ValueError: 107 | return -1.0 108 | 109 | return 0.0 110 | 111 | def convert_size( 112 | self, 113 | num: Union[int, float], 114 | measure: str) -> float: 115 | """Converts "human" file size to size in bytes 116 | 117 | Args: 118 | num (Union[int,float]): Size 119 | measure (str): Units (B, kB, MB, GB) 120 | 121 | Returns: 122 | Size in bytes 123 | """ 124 | 125 | measure_match = { 126 | 'B': 1, 127 | 'kB': 1000, 128 | 'MB': 1000000, 129 | 'GB': 1000000000 130 | } 131 | return measure_match.get(measure, -1) * num 132 | 133 | def get_file(self, path: str) -> Optional[AternosFile]: 134 | """Returns :class:`python_aternos.atfile.AternosFile` 135 | instance by its path 136 | 137 | Args: 138 | path (str): Path to the file including its filename 139 | 140 | Returns: 141 | atfile.AternosFile object 142 | if file has been found, 143 | otherwise None 144 | """ 145 | 146 | filedir = path[:path.rfind('/')] 147 | filename = path[path.rfind('/'):] 148 | 149 | files = self.list_dir(filedir) 150 | 151 | return { 152 | 'file': f 153 | for f in files 154 | if f.name == filename 155 | }.get('file', None) 156 | 157 | def dl_file(self, path: str) -> bytes: 158 | """Returns the file content in bytes (downloads it) 159 | 160 | Args: 161 | path (str): Path to file including its filename 162 | 163 | Returns: 164 | File content 165 | """ 166 | 167 | file = self.atserv.atserver_request( # type: ignore 168 | f'{AJAX_URL}/files/download.php' 169 | 'GET', params={ 170 | 'file': path.replace('/', '%2F') 171 | } 172 | ) 173 | 174 | return file.content 175 | 176 | def dl_world(self, world: str = 'world') -> bytes: 177 | """Returns the world zip file content 178 | by its name (downloads it) 179 | 180 | Args: 181 | world (str, optional): Name of world 182 | 183 | Returns: 184 | ZIP file content 185 | """ 186 | 187 | resp = self.atserv.atserver_request( # type: ignore 188 | f'{AJAX_URL}/worlds/download.php' 189 | 'GET', params={ 190 | 'world': world.replace('/', '%2F') 191 | } 192 | ) 193 | 194 | return resp.content 195 | -------------------------------------------------------------------------------- /python_aternos/atjsparse.py: -------------------------------------------------------------------------------- 1 | """Parsing and executing JavaScript code""" 2 | 3 | import abc 4 | 5 | import json 6 | import base64 7 | 8 | import subprocess 9 | 10 | from pathlib import Path 11 | from typing import Optional, Union 12 | from typing import Type, Any 13 | 14 | import regex 15 | import js2py 16 | import requests 17 | 18 | from .atlog import log 19 | 20 | 21 | js: Optional['Interpreter'] = None 22 | 23 | 24 | class Interpreter(abc.ABC): 25 | """Base JS interpreter class""" 26 | 27 | def __init__(self) -> None: 28 | """Base JS interpreter class""" 29 | 30 | def __getitem__(self, name: str) -> Any: 31 | """Support for `js[name]` syntax 32 | instead of `js.get_var(name)` 33 | 34 | Args: 35 | name (str): Variable name 36 | 37 | Returns: 38 | Variable value 39 | """ 40 | return self.get_var(name) 41 | 42 | @abc.abstractmethod 43 | def exec_js(self, func: str) -> None: 44 | """Executes JavaScript code 45 | 46 | Args: 47 | func (str): JS function 48 | """ 49 | 50 | @abc.abstractmethod 51 | def get_var(self, name: str) -> Any: 52 | """Returns JS variable value 53 | from the interpreter 54 | 55 | Args: 56 | name (str): Variable name 57 | 58 | Returns: 59 | Variable value 60 | """ 61 | 62 | 63 | class NodeInterpreter(Interpreter): 64 | """Node.JS interpreter wrapper, 65 | starts a simple web server in background""" 66 | 67 | def __init__( 68 | self, 69 | node: Union[str, Path] = 'node', 70 | host: str = 'localhost', 71 | port: int = 8001) -> None: 72 | """Node.JS interpreter wrapper, 73 | starts a simple web server in background 74 | 75 | Args: 76 | node (Union[str, Path], optional): Path to `node` executable 77 | host (str, optional): Hostname for the web server 78 | port (int, optional): Port for the web server 79 | """ 80 | 81 | super().__init__() 82 | 83 | file_dir = Path(__file__).absolute().parent 84 | server_js = file_dir / 'data' / 'server.js' 85 | 86 | self.url = f'http://{host}:{port}' 87 | self.timeout = 2 88 | 89 | # pylint: disable=consider-using-with 90 | self.proc = subprocess.Popen( 91 | args=[ 92 | node, server_js, 93 | f'{port}', host, 94 | ], 95 | stdout=subprocess.PIPE, 96 | ) 97 | # pylint: enable=consider-using-with 98 | 99 | assert self.proc.stdout is not None 100 | ok_msg = self.proc.stdout.readline() 101 | log.debug('Received from server.js: %s', ok_msg) 102 | 103 | def exec_js(self, func: str) -> None: 104 | resp = requests.post(self.url, data=func, timeout=self.timeout) 105 | resp.raise_for_status() 106 | 107 | def get_var(self, name: str) -> Any: 108 | resp = requests.post(self.url, data=name, timeout=self.timeout) 109 | resp.raise_for_status() 110 | log.debug('NodeJS response: %s', resp.content) 111 | return json.loads(resp.content) 112 | 113 | def __del__(self) -> None: 114 | try: 115 | self.proc.terminate() 116 | self.proc.communicate() 117 | except AttributeError: 118 | log.warning( 119 | 'NodeJS process was not initialized, ' 120 | 'but __del__ was called' 121 | ) 122 | 123 | 124 | class Js2PyInterpreter(Interpreter): 125 | """Js2Py interpreter, 126 | uses js2py library to execute code""" 127 | 128 | # Thanks to http://regex.inginf.units.it 129 | arrowexp = regex.compile(r'\w[^\}]*+') 130 | 131 | def __init__(self) -> None: 132 | """Js2Py interpreter, 133 | uses js2py library to execute code""" 134 | 135 | super().__init__() 136 | 137 | ctx = js2py.EvalJs({'atob': atob}) 138 | ctx.execute(''' 139 | window.Map = function(_i){ }; 140 | window.setTimeout = function(_f,_t){ }; 141 | window.setInterval = function(_f,_t){ }; 142 | window.encodeURIComponent = window.Map; 143 | window.document = { }; 144 | document.doctype = { }; 145 | document.currentScript = { }; 146 | document.getElementById = window.Map; 147 | document.prepend = window.Map; 148 | document.append = window.Map; 149 | document.appendChild = window.Map; 150 | ''') 151 | 152 | self.ctx = ctx 153 | 154 | def exec_js(self, func: str) -> None: 155 | self.ctx.execute(self.to_ecma5(func)) 156 | 157 | def get_var(self, name: str) -> Any: 158 | return self.ctx[name] 159 | 160 | def to_ecma5(self, func: str) -> str: 161 | """Converts from ECMA6 format to ECMA5 162 | (replacing arrow expressions) 163 | and removes comment blocks 164 | 165 | Args: 166 | func (str): ECMA6 function 167 | 168 | Returns: 169 | ECMA5 function 170 | """ 171 | 172 | # Delete anything between /* and */ 173 | func = regex.sub(r'/\*.+?\*/', '', func) 174 | 175 | # Search for arrow expressions 176 | match = self.arrowexp.search(func) 177 | if match is None: 178 | return func 179 | 180 | # Convert the function 181 | conv = '(function(){' + match[0] + '})()' 182 | 183 | # Convert 1 more expression. 184 | # It doesn't change, 185 | # so it was hardcoded 186 | # as a regexp 187 | return regex.sub( 188 | r'(?:s|\(s\)) => s.split\([\'"]{2}\).reverse\(\).join\([\'"]{2}\)', 189 | 'function(s){return s.split(\'\').reverse().join(\'\')}', 190 | conv 191 | ) 192 | 193 | 194 | def atob(s: str) -> str: 195 | """Wrapper for the built-in library function. 196 | Decodes a base64 string 197 | 198 | Args: 199 | s (str): Encoded data 200 | 201 | Returns: 202 | Decoded string 203 | """ 204 | 205 | return base64.standard_b64decode(str(s)).decode('utf-8') 206 | 207 | 208 | def get_interpreter( 209 | *args, 210 | create: Type[Interpreter] = Js2PyInterpreter, 211 | **kwargs) -> 'Interpreter': 212 | """Get or create a JS interpreter. 213 | `*args` and `**kwargs` will be passed 214 | directly to JS interpreter `__init__` 215 | (when creating it) 216 | 217 | Args: 218 | create (Type[Interpreter], optional): Preferred interpreter 219 | 220 | Returns: 221 | JS interpreter instance 222 | """ 223 | 224 | global js # pylint: disable=global-statement 225 | 226 | # create if none 227 | if js is None: 228 | js = create(*args, **kwargs) 229 | 230 | # and return 231 | return js 232 | -------------------------------------------------------------------------------- /python_aternos/atlog.py: -------------------------------------------------------------------------------- 1 | """Creates a logger""" 2 | 3 | import logging 4 | 5 | 6 | log = logging.getLogger('aternos') 7 | handler = logging.StreamHandler() 8 | fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s') 9 | 10 | handler.setFormatter(fmt) 11 | log.addHandler(handler) 12 | 13 | 14 | def is_debug() -> bool: 15 | """Is debug logging enabled""" 16 | 17 | return log.level == logging.DEBUG 18 | 19 | 20 | def set_debug(state: bool) -> None: 21 | """Enable debug logging""" 22 | 23 | if state: 24 | set_level(logging.DEBUG) 25 | else: 26 | set_level(logging.WARNING) 27 | 28 | 29 | def set_level(level: int) -> None: 30 | log.setLevel(level) 31 | handler.setLevel(level) 32 | -------------------------------------------------------------------------------- /python_aternos/atmd5.py: -------------------------------------------------------------------------------- 1 | """Contains a function for hashing""" 2 | 3 | import hashlib 4 | 5 | 6 | def md5encode(passwd: str) -> str: 7 | """Encodes the given string with MD5 8 | 9 | Args: 10 | passwd (str): String to encode 11 | 12 | Returns: 13 | Hexdigest hash of the string in lowercase 14 | """ 15 | 16 | encoded = hashlib.md5(passwd.encode('utf-8')) 17 | return encoded.hexdigest().lower() 18 | -------------------------------------------------------------------------------- /python_aternos/atplayers.py: -------------------------------------------------------------------------------- 1 | """Operators, whitelist and banned players lists""" 2 | 3 | import enum 4 | 5 | from typing import List, Union 6 | from typing import TYPE_CHECKING 7 | 8 | import lxml.html 9 | 10 | from .atconnect import BASE_URL, AJAX_URL 11 | if TYPE_CHECKING: 12 | from .atserver import AternosServer 13 | 14 | 15 | class Lists(enum.Enum): 16 | 17 | """Players list type enum""" 18 | 19 | whl = 'whitelist' 20 | whl_je = 'whitelist' 21 | whl_be = 'allowlist' 22 | ops = 'ops' 23 | ban = 'banned-players' 24 | ips = 'banned-ips' 25 | 26 | 27 | class PlayersList: 28 | 29 | """Class for managing operators, 30 | whitelist and banned players lists""" 31 | 32 | def __init__( 33 | self, 34 | lst: Union[str, Lists], 35 | atserv: 'AternosServer') -> None: 36 | """Class for managing operators, 37 | whitelist and banned players lists 38 | 39 | Args: 40 | lst (Union[str,Lists]): Players list type, must be 41 | atplayers.Lists enum value 42 | atserv (python_aternos.atserver.AternosServer): 43 | atserver.AternosServer instance 44 | """ 45 | 46 | self.atserv = atserv 47 | self.lst = Lists(lst) 48 | 49 | # Fix for #30 issue 50 | # whl_je = whitelist for java 51 | # whl_be = whitelist for bedrock 52 | # whl = common whitelist 53 | common_whl = self.lst == Lists.whl 54 | bedrock = atserv.is_bedrock 55 | 56 | if common_whl and bedrock: 57 | self.lst = Lists.whl_be 58 | 59 | self.players: List[str] = [] 60 | self.parsed = False 61 | 62 | def list_players(self, cache: bool = True) -> List[str]: 63 | """Parse a players list 64 | 65 | Args: 66 | cache (bool, optional): If the function should 67 | return cached list (highly recommended) 68 | 69 | Returns: 70 | List of players' nicknames 71 | """ 72 | 73 | if cache and self.parsed: 74 | return self.players 75 | 76 | listreq = self.atserv.atserver_request( 77 | f'{BASE_URL}/players/{self.lst.value}', 78 | 'GET' 79 | ) 80 | listtree = lxml.html.fromstring(listreq.content) 81 | items = listtree.xpath( 82 | '//div[@class="list-item"]' 83 | ) 84 | 85 | result = [] 86 | for i in items: 87 | name = i.xpath('./div[@class="list-name"]') 88 | result.append(name[0].text.strip()) 89 | 90 | self.players = result 91 | self.parsed = True 92 | return result 93 | 94 | def add(self, name: str) -> None: 95 | """Appends a player to the list by the nickname 96 | 97 | Args: 98 | name (str): Player's nickname 99 | """ 100 | 101 | self.atserv.atserver_request( 102 | f'{AJAX_URL}/server/players/lists/add', 103 | 'POST', data={ 104 | 'list': self.lst.value, 105 | 'name': name 106 | }, sendtoken=True 107 | ) 108 | 109 | self.players.append(name) 110 | 111 | def remove(self, name: str) -> None: 112 | """Removes a player from the list by the nickname 113 | 114 | Args: 115 | name (str): Player's nickname 116 | """ 117 | 118 | self.atserv.atserver_request( 119 | f'{AJAX_URL}/server/players/lists/remove', 120 | 'POST', data={ 121 | 'list': self.lst.value, 122 | 'name': name 123 | }, sendtoken=True 124 | ) 125 | 126 | for i, j in enumerate(self.players): 127 | if j == name: 128 | del self.players[i] 129 | -------------------------------------------------------------------------------- /python_aternos/atserver.py: -------------------------------------------------------------------------------- 1 | """Aternos Minecraft server""" 2 | 3 | import re 4 | import json 5 | 6 | import enum 7 | from typing import Any, Dict, List 8 | from functools import partial 9 | 10 | from .atconnect import BASE_URL, AJAX_URL 11 | from .atconnect import AternosConnect 12 | from .atwss import AternosWss 13 | 14 | from .atplayers import PlayersList 15 | from .atplayers import Lists 16 | 17 | from .atfm import FileManager 18 | from .atconf import AternosConfig 19 | 20 | from .aterrors import AternosError 21 | from .aterrors import ServerStartError 22 | 23 | 24 | SERVER_URL = f'{AJAX_URL}/server' 25 | status_re = re.compile( 26 | r'