rconx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rconx/__init__.py +68 -0
- rconx/__main__.py +5 -0
- rconx/cli/__init__.py +0 -0
- rconx/cli/arguments.py +128 -0
- rconx/cli/main.py +78 -0
- rconx/client/__init__.py +0 -0
- rconx/client/auth/__init__.py +1 -0
- rconx/client/auth/base.py +13 -0
- rconx/client/auth/source_handler.py +69 -0
- rconx/client/client.py +463 -0
- rconx/client/client_options.py +17 -0
- rconx/client/command/__init__.py +1 -0
- rconx/client/command/base.py +13 -0
- rconx/client/command/common.py +11 -0
- rconx/client/command/cuberite_handler.py +70 -0
- rconx/client/command/idle_timeout_handler.py +50 -0
- rconx/client/command/minecraft_handler.py +65 -0
- rconx/client/command/single_packet_handler.py +35 -0
- rconx/client/command/srcds_handler.py +80 -0
- rconx/client/operation.py +82 -0
- rconx/client/preset.py +22 -0
- rconx/client/raw.py +235 -0
- rconx/common/__init__.py +0 -0
- rconx/common/async_utils.py +26 -0
- rconx/common/connection.py +412 -0
- rconx/common/connection_info.py +21 -0
- rconx/common/connection_options.py +15 -0
- rconx/common/content.py +28 -0
- rconx/common/exceptions.py +26 -0
- rconx/common/protocol.py +66 -0
- rconx/common/thread_pool.py +162 -0
- rconx/common/utils.py +63 -0
- rconx/py.typed +0 -0
- rconx/server/__init__.py +0 -0
- rconx/server/config.py +34 -0
- rconx/server/log.py +5 -0
- rconx/server/raw.py +301 -0
- rconx/server/raw_options.py +12 -0
- rconx/server/server.py +233 -0
- rconx/server/server_connection.py +75 -0
- rconx/server/server_options.py +35 -0
- rconx/server/session.py +173 -0
- rconx-0.1.0.dist-info/METADATA +93 -0
- rconx-0.1.0.dist-info/RECORD +46 -0
- rconx-0.1.0.dist-info/WHEEL +4 -0
- rconx-0.1.0.dist-info/licenses/LICENSE +165 -0
rconx/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from rconx.client.client import AsyncRconClient, RconClient
|
|
2
|
+
from rconx.client.client_options import RconClientOptions
|
|
3
|
+
from rconx.client.preset import RconPreset
|
|
4
|
+
from rconx.client.raw import AsyncRawRconClient, LocalAddress, RawRconClient
|
|
5
|
+
from rconx.common.connection import AsyncConnection, Connection
|
|
6
|
+
from rconx.common.connection_info import ConnectionAddress, ConnectionInfo
|
|
7
|
+
from rconx.common.connection_options import ConnectionOptions
|
|
8
|
+
from rconx.common.content import RconContent
|
|
9
|
+
from rconx.common.exceptions import (
|
|
10
|
+
RconAuthenticationError,
|
|
11
|
+
RconConnectionError,
|
|
12
|
+
RconError,
|
|
13
|
+
RconPacketDecodeError,
|
|
14
|
+
RconProtocolError,
|
|
15
|
+
RconStateError,
|
|
16
|
+
RconTimeoutError,
|
|
17
|
+
)
|
|
18
|
+
from rconx.common.protocol import Packet, PacketType
|
|
19
|
+
from rconx.server.raw import AsyncRawRconServer, RawRconServer
|
|
20
|
+
from rconx.server.raw_options import RawRconServerOptions
|
|
21
|
+
from rconx.server.server import AsyncRconServer, RconServer
|
|
22
|
+
from rconx.server.server_options import RconServerOptions
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# High-level clients
|
|
26
|
+
'RconClient',
|
|
27
|
+
'AsyncRconClient',
|
|
28
|
+
'RconPreset',
|
|
29
|
+
'RconClientOptions',
|
|
30
|
+
|
|
31
|
+
# Low-level clients
|
|
32
|
+
'RawRconClient',
|
|
33
|
+
'AsyncRawRconClient',
|
|
34
|
+
'LocalAddress',
|
|
35
|
+
|
|
36
|
+
# High-level servers
|
|
37
|
+
'RconServer',
|
|
38
|
+
'AsyncRconServer',
|
|
39
|
+
'RconServerOptions',
|
|
40
|
+
|
|
41
|
+
# Low-level servers
|
|
42
|
+
'RawRconServer',
|
|
43
|
+
'AsyncRawRconServer',
|
|
44
|
+
'RawRconServerOptions',
|
|
45
|
+
|
|
46
|
+
# Low-level connections
|
|
47
|
+
'Connection',
|
|
48
|
+
'AsyncConnection',
|
|
49
|
+
'ConnectionOptions',
|
|
50
|
+
|
|
51
|
+
# Shared data
|
|
52
|
+
'RconContent',
|
|
53
|
+
'ConnectionInfo',
|
|
54
|
+
'ConnectionAddress',
|
|
55
|
+
|
|
56
|
+
# Protocol
|
|
57
|
+
'Packet',
|
|
58
|
+
'PacketType',
|
|
59
|
+
|
|
60
|
+
# Exceptions
|
|
61
|
+
'RconError',
|
|
62
|
+
'RconConnectionError',
|
|
63
|
+
'RconTimeoutError',
|
|
64
|
+
'RconPacketDecodeError',
|
|
65
|
+
'RconAuthenticationError',
|
|
66
|
+
'RconStateError',
|
|
67
|
+
'RconProtocolError',
|
|
68
|
+
]
|
rconx/__main__.py
ADDED
rconx/cli/__init__.py
ADDED
|
File without changes
|
rconx/cli/arguments.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import dataclasses
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from rconx.client.preset import RconPreset
|
|
8
|
+
from rconx.common.connection_info import ConnectionAddress
|
|
9
|
+
from rconx.common.utils import validate_packet_size_limit, validate_timeout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __get_self_version() -> str:
|
|
13
|
+
try:
|
|
14
|
+
if sys.version_info >= (3, 8):
|
|
15
|
+
from importlib.metadata import version
|
|
16
|
+
else:
|
|
17
|
+
from importlib_metadata import version
|
|
18
|
+
return version('rconx')
|
|
19
|
+
except Exception:
|
|
20
|
+
return 'unknown'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass(frozen=True)
|
|
24
|
+
class SizeLimit:
|
|
25
|
+
value: Optional[int]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass(frozen=True)
|
|
29
|
+
class CliOptions:
|
|
30
|
+
address: ConnectionAddress
|
|
31
|
+
password: str = dataclasses.field(repr=False)
|
|
32
|
+
commands: List[str]
|
|
33
|
+
preset: RconPreset
|
|
34
|
+
interactive: bool
|
|
35
|
+
timeout: Optional[float]
|
|
36
|
+
idle_window: Optional[float]
|
|
37
|
+
encoding: Optional[str]
|
|
38
|
+
raw: bool
|
|
39
|
+
max_send_packet_size: Optional[SizeLimit]
|
|
40
|
+
max_receive_packet_size: Optional[SizeLimit]
|
|
41
|
+
max_response_size: Optional[SizeLimit]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def compute_address(value: str) -> ConnectionAddress:
|
|
45
|
+
if value.startswith('['):
|
|
46
|
+
closing = value.find(']')
|
|
47
|
+
if closing < 0 or value[closing + 1:closing + 2] != ':':
|
|
48
|
+
raise ValueError('expected HOST:PORT or [IPv6]:PORT')
|
|
49
|
+
host = value[1:closing]
|
|
50
|
+
port_text = value[closing + 2:]
|
|
51
|
+
if ':' not in host or '[' in host:
|
|
52
|
+
raise ValueError('expected [IPv6]:PORT')
|
|
53
|
+
else:
|
|
54
|
+
host, separator, port_text = value.rpartition(':')
|
|
55
|
+
if not separator or ':' in host or '[' in host or ']' in host:
|
|
56
|
+
raise ValueError('expected HOST:PORT or [IPv6]:PORT')
|
|
57
|
+
if not host:
|
|
58
|
+
raise ValueError('host must not be empty')
|
|
59
|
+
if not port_text or any(character < '0' or character > '9' for character in port_text):
|
|
60
|
+
raise ValueError('port must be a decimal integer from 1 to 65535')
|
|
61
|
+
port = int(port_text)
|
|
62
|
+
if not 1 <= port <= 65535:
|
|
63
|
+
raise ValueError('port must be from 1 to 65535')
|
|
64
|
+
return ConnectionAddress(host, port)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def seconds(value: str) -> float:
|
|
68
|
+
seconds = float(value)
|
|
69
|
+
validate_timeout(seconds)
|
|
70
|
+
return seconds
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def timeout(value: str) -> Optional[float]:
|
|
74
|
+
return None if value == 'none' else seconds(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def size_limit(value: str) -> SizeLimit:
|
|
78
|
+
size = None if value == 'none' else int(value)
|
|
79
|
+
validate_packet_size_limit(size, 'size limit')
|
|
80
|
+
return SizeLimit(size)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_args() -> CliOptions:
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
prog='rconx',
|
|
86
|
+
usage='%(prog)s [OPTIONS] HOST:PORT [COMMAND ...]',
|
|
87
|
+
description='Put every local option before HOST:PORT; use [IPv6]:PORT for IPv6 addresses.',
|
|
88
|
+
epilog='Each COMMAND is one complete command string. After HOST:PORT, all arguments are commands, including --help, -i and --. '
|
|
89
|
+
'Without -i, zero commands check the connection and authentication. With -i, interaction follows the startup commands.',
|
|
90
|
+
)
|
|
91
|
+
password_source = parser.add_mutually_exclusive_group(required=True)
|
|
92
|
+
password_source.add_argument('-p', '--password', metavar='PASSWORD', help='password; an explicitly empty value is allowed')
|
|
93
|
+
password_source.add_argument('--password-env', metavar='NAME', help='read the password from environment variable NAME')
|
|
94
|
+
parser.add_argument('-P', '--preset', choices=[preset.name for preset in RconPreset], default='minecraft', help='RCON exchange preset (default: %(default)s)')
|
|
95
|
+
parser.add_argument('-i', '--interactive', action='store_true', help='read commands interactively after the startup commands')
|
|
96
|
+
parser.add_argument('-t', '--timeout', type=timeout, default=10.0, metavar='SECONDS|none', help='timeout for each connect, authentication or command operation (default: 10 seconds)')
|
|
97
|
+
parser.add_argument('--idle-window', type=seconds, metavar='SECONDS', help='response quiet window; required and valid only for the idle_timeout preset')
|
|
98
|
+
parser.add_argument('--encoding', metavar='NAME', help='password, command and response text encoding (default: core utf8)')
|
|
99
|
+
parser.add_argument('--raw', action='store_true', help='write response bytes; incompatible with -i')
|
|
100
|
+
parser.add_argument('--max-send-packet-size', type=size_limit, metavar='BYTES|none', help='send packet size limit; none disables it (default: selected preset)')
|
|
101
|
+
parser.add_argument('--max-receive-packet-size', type=size_limit, metavar='BYTES|none', help='receive packet size limit; none disables it (default: core limit)')
|
|
102
|
+
parser.add_argument('--max-response-size', type=size_limit, metavar='BYTES|none', help='accumulated response size limit; none disables it (default: core limit)')
|
|
103
|
+
parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__get_self_version()))
|
|
104
|
+
parser.add_argument('items', nargs=argparse.REMAINDER, metavar='HOST:PORT [COMMAND ...]', help='target address followed by zero or more complete command strings')
|
|
105
|
+
arguments = parser.parse_args()
|
|
106
|
+
|
|
107
|
+
if not arguments.items:
|
|
108
|
+
raise ValueError('HOST:PORT is required')
|
|
109
|
+
if (arguments.preset == 'idle_timeout') != (arguments.idle_window is not None):
|
|
110
|
+
raise ValueError('--idle-window is required and valid only for idle_timeout')
|
|
111
|
+
if arguments.raw and arguments.interactive:
|
|
112
|
+
raise ValueError('--raw cannot be combined with --interactive')
|
|
113
|
+
password = os.environ[arguments.password_env] if arguments.password_env is not None else arguments.password
|
|
114
|
+
|
|
115
|
+
return CliOptions(
|
|
116
|
+
address=compute_address(arguments.items[0]),
|
|
117
|
+
password=password,
|
|
118
|
+
commands=arguments.items[1:],
|
|
119
|
+
preset=RconPreset[arguments.preset],
|
|
120
|
+
interactive=arguments.interactive,
|
|
121
|
+
timeout=arguments.timeout,
|
|
122
|
+
idle_window=arguments.idle_window,
|
|
123
|
+
encoding=arguments.encoding,
|
|
124
|
+
raw=arguments.raw,
|
|
125
|
+
max_send_packet_size=arguments.max_send_packet_size,
|
|
126
|
+
max_receive_packet_size=arguments.max_receive_packet_size,
|
|
127
|
+
max_response_size=arguments.max_response_size,
|
|
128
|
+
)
|
rconx/cli/main.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from rconx.cli.arguments import CliOptions, parse_args
|
|
5
|
+
from rconx.client.client import RconClient
|
|
6
|
+
from rconx.client.client_options import RconClientOptions
|
|
7
|
+
from rconx.common.content import RconContent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
options = parse_args()
|
|
12
|
+
with __create_client(options) as client:
|
|
13
|
+
__run(client, options)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __create_client(options: CliOptions) -> RconClient:
|
|
17
|
+
client_options: RconClientOptions = {}
|
|
18
|
+
if options.encoding is not None:
|
|
19
|
+
client_options['default_encoding'] = options.encoding
|
|
20
|
+
if options.max_send_packet_size is not None:
|
|
21
|
+
client_options['max_send_packet_size'] = options.max_send_packet_size.value
|
|
22
|
+
if options.max_receive_packet_size is not None:
|
|
23
|
+
client_options['max_receive_packet_size'] = options.max_receive_packet_size.value
|
|
24
|
+
if options.max_response_size is not None:
|
|
25
|
+
client_options['max_response_size'] = options.max_response_size.value
|
|
26
|
+
return RconClient.create(
|
|
27
|
+
options.address.host, options.address.port, options.preset,
|
|
28
|
+
first_byte_timeout=options.idle_window,
|
|
29
|
+
**client_options,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __run(client: RconClient, options: CliOptions):
|
|
34
|
+
client.connect(timeout=options.timeout)
|
|
35
|
+
client.authenticate(options.password, timeout=options.timeout)
|
|
36
|
+
|
|
37
|
+
for option_command in options.commands:
|
|
38
|
+
__execute_command(client, option_command, options)
|
|
39
|
+
if not options.interactive:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
while True:
|
|
43
|
+
user_command = __read_command()
|
|
44
|
+
if user_command is None:
|
|
45
|
+
return
|
|
46
|
+
if user_command:
|
|
47
|
+
__execute_command(client, user_command, options)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def __execute_command(client: RconClient, command: str, options: CliOptions):
|
|
51
|
+
response = client.execute(command, timeout=options.timeout)
|
|
52
|
+
__write_result(response, options)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def __read_command() -> Optional[str]:
|
|
56
|
+
sys.stderr.write('rconx> ')
|
|
57
|
+
sys.stderr.flush()
|
|
58
|
+
try:
|
|
59
|
+
line = sys.stdin.readline()
|
|
60
|
+
if not line:
|
|
61
|
+
return None
|
|
62
|
+
if line.endswith('\n'):
|
|
63
|
+
line = line[:-1]
|
|
64
|
+
if line.endswith('\r'):
|
|
65
|
+
line = line[:-1]
|
|
66
|
+
except (KeyboardInterrupt, EOFError):
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
return line
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def __write_result(response: RconContent, options: CliOptions):
|
|
72
|
+
if options.raw:
|
|
73
|
+
sys.stdout.buffer.write(response.content)
|
|
74
|
+
sys.stdout.buffer.flush()
|
|
75
|
+
else:
|
|
76
|
+
sys.stdout.write(response.text)
|
|
77
|
+
sys.stdout.write('\n')
|
|
78
|
+
sys.stdout.flush()
|
rconx/client/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from rconx.client.operation import AsyncRconOperationContext, RconOperationContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthenticationHandler(ABC):
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def authenticate(self, context: RconOperationContext, password: bytes):
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def authenticate_async(self, context: AsyncRconOperationContext, password: bytes):
|
|
13
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from rconx.client.auth.base import AuthenticationHandler
|
|
2
|
+
from rconx.client.operation import AsyncRconOperationContext, RconOperationContext
|
|
3
|
+
from rconx.common.exceptions import RconAuthenticationError, RconProtocolError
|
|
4
|
+
from rconx.common.protocol import Packet, PacketType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _AuthenticationResponseProcessor:
|
|
8
|
+
def __init__(self, request_id: int):
|
|
9
|
+
self.__request_id = request_id
|
|
10
|
+
self.__received_empty_response = False
|
|
11
|
+
self.__is_complete = False
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def is_complete(self) -> bool:
|
|
15
|
+
return self.__is_complete
|
|
16
|
+
|
|
17
|
+
def accept(self, packet: Packet) -> None:
|
|
18
|
+
if packet.payload:
|
|
19
|
+
raise RconProtocolError('authentication response payload length is {}, expected 0 (request id {})'.format(len(packet.payload), packet.request_id))
|
|
20
|
+
|
|
21
|
+
if packet.packet_type == PacketType.auth_response:
|
|
22
|
+
if packet.request_id == -1:
|
|
23
|
+
raise RconAuthenticationError('server rejected authentication for request id {}'.format(self.__request_id))
|
|
24
|
+
if packet.request_id != self.__request_id:
|
|
25
|
+
raise RconProtocolError('unexpected authentication result request id {}, expected {}'.format(packet.request_id, self.__request_id))
|
|
26
|
+
|
|
27
|
+
self.__is_complete = True
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
# Allow at most one empty response-value packet before the authentication result.
|
|
31
|
+
if packet.packet_type != PacketType.response_value:
|
|
32
|
+
raise RconProtocolError(
|
|
33
|
+
'unexpected authentication response type {}, expected {} (response value) or {} (authentication result)'.format(
|
|
34
|
+
packet.packet_type, PacketType.response_value, PacketType.auth_response,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
if packet.request_id != self.__request_id:
|
|
38
|
+
raise RconProtocolError('unexpected empty authentication response request id {}, expected {}'.format(packet.request_id, self.__request_id))
|
|
39
|
+
|
|
40
|
+
if self.__received_empty_response:
|
|
41
|
+
raise RconProtocolError('received a second empty response-value packet before the authentication result (request id {})'.format(packet.request_id))
|
|
42
|
+
|
|
43
|
+
self.__received_empty_response = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SourceAuthenticationHandler(AuthenticationHandler):
|
|
47
|
+
"""
|
|
48
|
+
**Not public API**
|
|
49
|
+
|
|
50
|
+
Source RCON authentication for SRCDS, Minecraft and Cuberite.
|
|
51
|
+
Accepts an authentication result directly or after one empty response-value packet.
|
|
52
|
+
"""
|
|
53
|
+
def authenticate(self, context: RconOperationContext, password: bytes):
|
|
54
|
+
request = context.prepare_request(PacketType.auth, password)
|
|
55
|
+
response_processor = _AuthenticationResponseProcessor(request.request_id)
|
|
56
|
+
|
|
57
|
+
context.send_packet(request)
|
|
58
|
+
|
|
59
|
+
while not response_processor.is_complete:
|
|
60
|
+
response_processor.accept(context.receive_packet())
|
|
61
|
+
|
|
62
|
+
async def authenticate_async(self, context: AsyncRconOperationContext, password: bytes):
|
|
63
|
+
request = context.prepare_request(PacketType.auth, password)
|
|
64
|
+
response_processor = _AuthenticationResponseProcessor(request.request_id)
|
|
65
|
+
|
|
66
|
+
await context.send_packet(request)
|
|
67
|
+
|
|
68
|
+
while not response_processor.is_complete:
|
|
69
|
+
response_processor.accept(await context.receive_packet())
|