exposr 0.5.2__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.
client/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Exposr client package."""
client/config.py ADDED
@@ -0,0 +1,43 @@
1
+ import json
2
+ import secrets
3
+ from pathlib import Path
4
+
5
+
6
+ CONFIG_PATH = Path.home() / ".exposr" / "config.json"
7
+ TOKEN_PATH = CONFIG_PATH.parent / "agent_token.txt"
8
+
9
+
10
+ def load_config():
11
+ if not CONFIG_PATH.exists():
12
+ return {"server_host": "", "agent_token": ""}
13
+ try:
14
+ with CONFIG_PATH.open("r", encoding="utf-8") as config_file:
15
+ config = json.load(config_file)
16
+ except (OSError, json.JSONDecodeError):
17
+ return {"server_host": "", "agent_token": ""}
18
+ return {
19
+ "server_host": str(config.get("server_host", "")).strip(),
20
+ "agent_token": str(config.get("agent_token", "")).strip(),
21
+ }
22
+
23
+
24
+ def set_server(server_host):
25
+ agent_token = secrets.token_urlsafe(32)
26
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
27
+ with CONFIG_PATH.open("w", encoding="utf-8") as config_file:
28
+ json.dump(
29
+ {"server_host": server_host.strip(), "agent_token": agent_token},
30
+ config_file,
31
+ indent=2,
32
+ )
33
+ config_file.write("\n")
34
+ TOKEN_PATH.write_text(f"{agent_token}\n", encoding="utf-8")
35
+ return agent_token
36
+
37
+
38
+ def get_server_host():
39
+ return load_config()["server_host"]
40
+
41
+
42
+ def get_agent_token():
43
+ return load_config()["agent_token"]
client/main.py ADDED
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+
3
+ from client.config import get_agent_token, get_server_host, set_server
4
+ from client.tcp.connection import AgentConfig as TcpAgentConfig, connect_to_server as connect_tcp
5
+ from client.udp.connection import AgentConfig as UdpAgentConfig, connect_to_server as connect_udp
6
+ from common.cli import parse_arguments
7
+ from common.logger import error, info
8
+
9
+
10
+ def main():
11
+ args = parse_arguments()
12
+ if args.command == "server":
13
+ from server.config import get_agent_token as get_server_agent_token
14
+ from server.config import init_token
15
+
16
+ if args.server_command == "init-token":
17
+ if not args.agent_token.strip():
18
+ error("Agent token cannot be empty")
19
+ return
20
+ init_token(args.agent_token)
21
+ info("Agent token saved to ~/.exposr/config.json")
22
+ elif args.server_command == "start":
23
+ if not get_server_agent_token():
24
+ error("Server token is not configured. Run: exposr server init-token <token>")
25
+ return
26
+ from server.main import main as start_server
27
+
28
+ try:
29
+ asyncio.run(start_server())
30
+ except KeyboardInterrupt:
31
+ print()
32
+ info("Exposr server stopped")
33
+ return
34
+ if args.command == "config":
35
+ if args.config_command == "set-server":
36
+ set_server(args.server_host)
37
+ info(f"Server address saved: {args.server_host}")
38
+ info("Agent token generated and saved to ~/.exposr/agent_token.txt")
39
+ return
40
+ server_host = args.server_host or get_server_host()
41
+ agent_token = get_agent_token()
42
+ if not server_host:
43
+ error(
44
+ "Server IP is not configured. "
45
+ "Run: exposr config set-server <server-ip>"
46
+ )
47
+ return
48
+ if not agent_token:
49
+ error(
50
+ "Agent token is not configured. "
51
+ "Run: exposr config set-server <server-ip>"
52
+ )
53
+ return
54
+
55
+ config_class = UdpAgentConfig if args.command == "udp" else TcpAgentConfig
56
+ connect = connect_udp if args.command == "udp" else connect_tcp
57
+ config = config_class(
58
+ server_host=server_host,
59
+ agent_token=agent_token,
60
+ control_port=args.control_port,
61
+ data_port=args.data_port,
62
+ local_host=args.local_host,
63
+ local_port=args.local_port,
64
+ preferred_public_port=args.public_port or 25565,
65
+ explicit_public_port=args.public_port is not None,
66
+ )
67
+ try:
68
+ asyncio.run(connect(config))
69
+ except KeyboardInterrupt:
70
+ print()
71
+ info("Exposr stopped")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
client/tcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """TCP client transport implementation."""
@@ -0,0 +1,86 @@
1
+ import asyncio
2
+ import random
3
+ from dataclasses import dataclass
4
+
5
+ from common.logger import connected, error, trying
6
+ from common.protocol import CONNECT, REGISTER, REGISTERED, command
7
+ from client.tcp.tunnel import handle_connection
8
+
9
+ RANDOM_PORT_START = 20000
10
+ RANDOM_PORT_END = 30000
11
+ MAX_PORT_ATTEMPTS = 100
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AgentConfig:
16
+ server_host: str
17
+ agent_token: str
18
+ control_port: int
19
+ data_port: int
20
+ local_host: str
21
+ local_port: int
22
+ preferred_public_port: int
23
+ explicit_public_port: bool
24
+
25
+
26
+ async def register_port(reader, writer, port, config):
27
+ trying(f"Trying to register public port {port}")
28
+ writer.write(command(REGISTER, f"{port} {config.agent_token}"))
29
+ await writer.drain()
30
+ response = await reader.readline()
31
+ if not response:
32
+ error("Server disconnected while registering")
33
+ return False
34
+ if response.startswith(REGISTERED.encode()):
35
+ connected(f"Public tunnel active: {config.server_host}:{port}")
36
+ connected(f"Forwarding to: {config.local_host}:{config.local_port}")
37
+ return True
38
+ error(f"Port {port} unavailable")
39
+ return False
40
+
41
+
42
+ async def connect_to_server(config):
43
+ while True:
44
+ writer = None
45
+ try:
46
+ trying(f"Connecting to Exposr {config.server_host}:{config.control_port}")
47
+ reader, writer = await asyncio.open_connection(
48
+ config.server_host, config.control_port
49
+ )
50
+ connected("Connected to Exposr control server")
51
+ public_port = config.preferred_public_port
52
+ registered = await register_port(reader, writer, public_port, config)
53
+ if not registered and config.explicit_public_port:
54
+ error(f"Requested public port {public_port} is unavailable")
55
+ raise RuntimeError(f"Port {public_port} is unavailable")
56
+ attempts = 0
57
+ while not registered:
58
+ attempts += 1
59
+ if attempts > MAX_PORT_ATTEMPTS:
60
+ error("Could not find an available public port after 100 attempts")
61
+ raise RuntimeError("No public ports available")
62
+ public_port = random.randint(RANDOM_PORT_START, RANDOM_PORT_END)
63
+ trying(f"Trying random public port {public_port}")
64
+ registered = await register_port(reader, writer, public_port, config)
65
+ while True:
66
+ command_line = await reader.readline()
67
+ if not command_line:
68
+ error("Server disconnected")
69
+ break
70
+ parts = command_line.decode().strip().split()
71
+ if len(parts) == 2 and parts[0] == CONNECT:
72
+ trying(f"Setting up connection {parts[1][:8]}")
73
+ asyncio.create_task(handle_connection(parts[1], config))
74
+ except asyncio.CancelledError:
75
+ raise
76
+ except Exception as exc:
77
+ error(f"Control connection error: {exc}")
78
+ finally:
79
+ if writer is not None:
80
+ writer.close()
81
+ try:
82
+ await writer.wait_closed()
83
+ except Exception:
84
+ pass
85
+ trying("Retrying connection in 5 seconds")
86
+ await asyncio.sleep(5)
client/tcp/tunnel.py ADDED
@@ -0,0 +1,44 @@
1
+ import asyncio
2
+
3
+ from common.logger import connected, error, trying
4
+ from common.protocol import command, DATA
5
+
6
+
7
+ async def pipe(reader, writer):
8
+ try:
9
+ while True:
10
+ data = await reader.read(65536)
11
+ if not data:
12
+ break
13
+ writer.write(data)
14
+ await writer.drain()
15
+ except Exception:
16
+ pass
17
+ finally:
18
+ try:
19
+ writer.close()
20
+ await writer.wait_closed()
21
+ except Exception:
22
+ pass
23
+
24
+
25
+ async def handle_connection(connection_id, config):
26
+ connected(f"New connection ({connection_id[:8]})")
27
+ try:
28
+ trying(f"Connecting to local service {config.local_host}:{config.local_port}")
29
+ local_reader, local_writer = await asyncio.open_connection(
30
+ config.local_host, config.local_port
31
+ )
32
+ trying(f"Opening data connection to {config.server_host}:{config.data_port}")
33
+ data_reader, data_writer = await asyncio.open_connection(
34
+ config.server_host, config.data_port
35
+ )
36
+ data_writer.write(command(DATA, connection_id))
37
+ await data_writer.drain()
38
+ connected(f"Tunnel established -> {config.local_host}:{config.local_port}")
39
+ await asyncio.gather(
40
+ pipe(data_reader, local_writer),
41
+ pipe(local_reader, data_writer),
42
+ )
43
+ except Exception as exc:
44
+ error(f"Connection failed: {exc}")
client/udp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Placeholder package for future UDP client transport support."""
@@ -0,0 +1,76 @@
1
+ import asyncio
2
+ import random
3
+ from dataclasses import dataclass
4
+
5
+ from common.logger import connected, error, trying
6
+ from common.protocol import CONNECT, REGISTER, REGISTERED, UDP, command
7
+ from client.udp.tunnel import handle_connection
8
+
9
+ RANDOM_PORT_START = 20000
10
+ RANDOM_PORT_END = 30000
11
+ MAX_PORT_ATTEMPTS = 100
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AgentConfig:
16
+ server_host: str
17
+ agent_token: str
18
+ control_port: int
19
+ data_port: int
20
+ local_host: str
21
+ local_port: int
22
+ preferred_public_port: int
23
+ explicit_public_port: bool
24
+
25
+
26
+ async def register_port(reader, writer, port, config):
27
+ trying(f"Trying to register UDP public port {port}")
28
+ writer.write(command(REGISTER, f"{port} {config.agent_token} {UDP}"))
29
+ await writer.drain()
30
+ response = await reader.readline()
31
+ if response.startswith(REGISTERED.encode()):
32
+ connected(f"UDP tunnel active: {config.server_host}:{port}")
33
+ connected(f"Forwarding UDP to: {config.local_host}:{config.local_port}")
34
+ return True
35
+ error(f"UDP port {port} unavailable")
36
+ return False
37
+
38
+
39
+ async def connect_to_server(config):
40
+ while True:
41
+ writer = None
42
+ try:
43
+ trying(f"Connecting to Exposr {config.server_host}:{config.control_port}")
44
+ reader, writer = await asyncio.open_connection(config.server_host, config.control_port)
45
+ connected("Connected to Exposr control server")
46
+ public_port = config.preferred_public_port
47
+ registered = await register_port(reader, writer, public_port, config)
48
+ if not registered and config.explicit_public_port:
49
+ raise RuntimeError(f"UDP port {public_port} is unavailable")
50
+ attempts = 0
51
+ while not registered:
52
+ attempts += 1
53
+ if attempts > MAX_PORT_ATTEMPTS:
54
+ raise RuntimeError("No UDP public ports available")
55
+ public_port = random.randint(RANDOM_PORT_START, RANDOM_PORT_END)
56
+ registered = await register_port(reader, writer, public_port, config)
57
+ while True:
58
+ line = await reader.readline()
59
+ if not line:
60
+ break
61
+ parts = line.decode().strip().split()
62
+ if len(parts) == 2 and parts[0] == CONNECT:
63
+ asyncio.create_task(handle_connection(parts[1], config))
64
+ except asyncio.CancelledError:
65
+ raise
66
+ except Exception as exc:
67
+ error(f"UDP control connection error: {exc}")
68
+ finally:
69
+ if writer is not None:
70
+ writer.close()
71
+ try:
72
+ await writer.wait_closed()
73
+ except Exception:
74
+ pass
75
+ trying("Retrying UDP connection in 5 seconds")
76
+ await asyncio.sleep(5)
client/udp/tunnel.py ADDED
@@ -0,0 +1,68 @@
1
+ import asyncio
2
+ import struct
3
+
4
+ from common.logger import connected, error, trying
5
+ from common.protocol import DATA, command
6
+
7
+
8
+ def pack_datagram(data):
9
+ return struct.pack("!I", len(data)) + data
10
+
11
+
12
+ async def read_datagram(reader):
13
+ header = await reader.readexactly(4)
14
+ size = struct.unpack("!I", header)[0]
15
+ if size > 65535:
16
+ raise ValueError("UDP datagram is too large")
17
+ return await reader.readexactly(size)
18
+
19
+
20
+ class LocalUdpProtocol(asyncio.DatagramProtocol):
21
+ def __init__(self, queue):
22
+ self.queue = queue
23
+
24
+ def datagram_received(self, data, address):
25
+ self.queue.put_nowait(data)
26
+
27
+
28
+ async def handle_connection(connection_id, config):
29
+ data_writer = None
30
+ local_transport = None
31
+ try:
32
+ trying(f"Connecting to local UDP service {config.local_host}:{config.local_port}")
33
+ queue = asyncio.Queue()
34
+ loop = asyncio.get_running_loop()
35
+ local_transport, _ = await loop.create_datagram_endpoint(
36
+ lambda: LocalUdpProtocol(queue),
37
+ remote_addr=(config.local_host, config.local_port),
38
+ )
39
+ trying(f"Opening UDP data connection to {config.server_host}:{config.data_port}")
40
+ data_reader, data_writer = await asyncio.open_connection(
41
+ config.server_host, config.data_port
42
+ )
43
+ data_writer.write(command(DATA, connection_id))
44
+ await data_writer.drain()
45
+ connected(f"UDP tunnel established -> {config.local_host}:{config.local_port}")
46
+
47
+ async def send_local_datagrams():
48
+ while True:
49
+ data = await queue.get()
50
+ data_writer.write(pack_datagram(data))
51
+ await data_writer.drain()
52
+
53
+ async def receive_local_datagrams():
54
+ while True:
55
+ local_transport.sendto(await read_datagram(data_reader))
56
+
57
+ await asyncio.gather(send_local_datagrams(), receive_local_datagrams())
58
+ except Exception as exc:
59
+ error(f"UDP connection failed: {exc}")
60
+ finally:
61
+ if local_transport is not None:
62
+ local_transport.close()
63
+ if data_writer is not None:
64
+ data_writer.close()
65
+ try:
66
+ await data_writer.wait_closed()
67
+ except Exception:
68
+ pass
common/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shared Exposr helpers."""
common/cli.py ADDED
@@ -0,0 +1,60 @@
1
+ import argparse
2
+
3
+
4
+ def parse_arguments(argv=None):
5
+ parser = argparse.ArgumentParser(
6
+ prog="exposr",
7
+ description="Exposr TCP and UDP tunneling agent",
8
+ )
9
+ subparsers = parser.add_subparsers(dest="command", required=True)
10
+
11
+ def add_tunnel_arguments(tunnel_parser):
12
+ tunnel_parser.add_argument("local_port", type=int)
13
+ tunnel_parser.add_argument(
14
+ "public_port",
15
+ nargs="?",
16
+ type=int,
17
+ default=None,
18
+ )
19
+
20
+ # Accept configuration flags both before and after the subcommand.
21
+ tunnel_parser.add_argument("--server-host", dest="server_host", default=argparse.SUPPRESS)
22
+ tunnel_parser.add_argument("--control-port", dest="control_port", type=int, default=argparse.SUPPRESS)
23
+ tunnel_parser.add_argument("--data-port", dest="data_port", type=int, default=argparse.SUPPRESS)
24
+ tunnel_parser.add_argument("--local-host", dest="local_host", default=argparse.SUPPRESS)
25
+
26
+ tcp_parser = subparsers.add_parser(
27
+ "tcp", help="Expose a local TCP port to the internet"
28
+ )
29
+ add_tunnel_arguments(tcp_parser)
30
+
31
+ udp_parser = subparsers.add_parser(
32
+ "udp", help="Expose a local UDP port to the internet"
33
+ )
34
+ add_tunnel_arguments(udp_parser)
35
+
36
+
37
+ config_parser = subparsers.add_parser("config", help="Manage Exposr configuration")
38
+ config_subparsers = config_parser.add_subparsers(dest="config_command", required=True)
39
+ set_server_parser = config_subparsers.add_parser(
40
+ "set-server", help="Set the Exposr relay server address"
41
+ )
42
+ set_server_parser.add_argument("server_host")
43
+
44
+ server_parser = subparsers.add_parser("server", help="Manage the Exposr relay server")
45
+ server_subparsers = server_parser.add_subparsers(
46
+ dest="server_command", required=True
47
+ )
48
+ init_token_parser = server_subparsers.add_parser(
49
+ "init-token", help="Set the relay server agent token"
50
+ )
51
+ init_token_parser.add_argument("agent_token")
52
+ server_subparsers.add_parser("start", help="Start the Exposr relay server")
53
+
54
+ parser.add_argument("--server-host", default=None)
55
+ parser.add_argument("--control-port", type=int, default=9000)
56
+ parser.add_argument("--data-port", type=int, default=9001)
57
+ parser.add_argument("--local-host", default="127.0.0.1")
58
+
59
+ args = parser.parse_args(argv)
60
+ return args
common/logger.py ADDED
@@ -0,0 +1,21 @@
1
+ GREEN = "\033[92m"
2
+ YELLOW = "\033[93m"
3
+ RED = "\033[91m"
4
+ BLUE = "\033[94m"
5
+ RESET = "\033[0m"
6
+
7
+
8
+ def connected(message):
9
+ print(f"{GREEN}[CONNECTED]{RESET} {message}")
10
+
11
+
12
+ def trying(message):
13
+ print(f"{YELLOW}[TRYING]{RESET} {message}")
14
+
15
+
16
+ def error(message):
17
+ print(f"{RED}[ERROR]{RESET} {message}")
18
+
19
+
20
+ def info(message):
21
+ print(f"{BLUE}[INFO]{RESET} {message}")
common/protocol.py ADDED
@@ -0,0 +1,10 @@
1
+ REGISTER = "REGISTER"
2
+ REGISTERED = "REGISTERED"
3
+ CONNECT = "CONNECT"
4
+ DATA = "DATA"
5
+ TCP = "TCP"
6
+ UDP = "UDP"
7
+
8
+
9
+ def command(name, value):
10
+ return f"{name} {value}\n".encode()