├── .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 |

3 |
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 |

3 |
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'