sonx 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.
son/__init__.py ADDED
File without changes
son/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from son.cli.main import app
2
+
3
+ app()
son/cli/__init__.py ADDED
File without changes
son/cli/connect.py ADDED
@@ -0,0 +1,77 @@
1
+ import asyncio
2
+ import sys
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.live import Live
8
+ from rich.panel import Panel
9
+
10
+ from son.client.connector import SonConnector
11
+ from son.core.protocol import CONNECTION_PORT
12
+ from son.core.types import get_default_shell
13
+
14
+ app = typer.Typer(help="Connect to a remote sond instance")
15
+ console = Console()
16
+
17
+
18
+ @app.callback(invoke_without_command=True)
19
+ def connect(
20
+ host: str = typer.Argument(..., help="sond hostname or IP address"),
21
+ port: int = typer.Option(CONNECTION_PORT, "--port", "-p", help="sond port"),
22
+ password: str = typer.Option("", "--password", "-P", help="auth password"),
23
+ shell: str = typer.Option("", "--shell", "-s", help="shell type (powershell, cmd, bash)"),
24
+ ):
25
+ target_shell = shell or get_default_shell()
26
+
27
+ async def run():
28
+ connector = SonConnector(host, port, password)
29
+ try:
30
+ await connector.connect()
31
+ except Exception as e:
32
+ console.print(f"[red]connection failed:[/] {e}")
33
+ raise typer.Exit(1)
34
+
35
+ console.print(Panel(
36
+ f"[bold green]connected[/] to [cyan]{host}:{port}[/] via [yellow]{target_shell}[/]\n"
37
+ "[dim]type 'exit' to disconnect[/]",
38
+ border_style="green",
39
+ ))
40
+
41
+ await connector.open_shell(shell=target_shell)
42
+
43
+ async def read_stdin():
44
+ loop = asyncio.get_running_loop()
45
+ while True:
46
+ line = await loop.run_in_executor(None, sys.stdin.readline)
47
+ if not line:
48
+ break
49
+ if line.strip().lower() == "exit":
50
+ break
51
+ await connector.send_input(line)
52
+
53
+ async def handle_output(data: str):
54
+ sys.stdout.write(data)
55
+ sys.stdout.flush()
56
+
57
+ connector.on_output(handle_output)
58
+ connector.on_disconnect(lambda: console.print("\n[red]disconnected[/]"))
59
+
60
+ tasks = [
61
+ asyncio.create_task(connector.listen()),
62
+ asyncio.create_task(read_stdin()),
63
+ ]
64
+
65
+ try:
66
+ await asyncio.gather(*tasks)
67
+ except (asyncio.CancelledError, KeyboardInterrupt):
68
+ pass
69
+ finally:
70
+ await connector.disconnect()
71
+ for t in tasks:
72
+ t.cancel()
73
+
74
+ try:
75
+ asyncio.run(run())
76
+ except KeyboardInterrupt:
77
+ pass
son/cli/discover.py ADDED
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.live import Live
9
+
10
+ from son.client.discovery import DiscoveryListener
11
+ from son.core.types import DiscoveryInfo
12
+
13
+ app = typer.Typer(help="Discover sond instances on the local network")
14
+ console = Console()
15
+
16
+
17
+ @app.callback(invoke_without_command=True)
18
+ def discover(
19
+ timeout: Optional[float] = typer.Option(None, "--timeout", "-t", help="scan duration in seconds"),
20
+ ):
21
+ discovered: list[DiscoveryInfo] = []
22
+ start = time.monotonic()
23
+
24
+ def on_discover(info: DiscoveryInfo):
25
+ for d in discovered:
26
+ if d.ip == info.ip and d.port == info.port:
27
+ return
28
+ discovered.append(info)
29
+ console.print(f" [green]✓[/] found [bold]{info.hostname}[/] at {info.ip}:{info.port}")
30
+
31
+ async def run():
32
+ listener = DiscoveryListener()
33
+ listener.on_discover(on_discover)
34
+ console.print("[dim]scanning for sond instances...[/]")
35
+ task = asyncio.create_task(listener.start())
36
+
37
+ if timeout:
38
+ await asyncio.sleep(timeout)
39
+ else:
40
+ await asyncio.sleep(5)
41
+
42
+ listener.stop()
43
+ task.cancel()
44
+ try:
45
+ await task
46
+ except asyncio.CancelledError:
47
+ pass
48
+
49
+ asyncio.run(run())
50
+
51
+ if not discovered:
52
+ console.print("\n[yellow]no sond instances found[/]")
53
+ return
54
+
55
+ table = Table(title=f"Found {len(discovered)} sond instance(s)")
56
+ table.add_column("Hostname", style="cyan")
57
+ table.add_column("IP", style="green")
58
+ table.add_column("Port", style="yellow")
59
+ table.add_column("OS")
60
+
61
+ for d in discovered:
62
+ table.add_row(d.hostname, d.ip, str(d.port), d.os)
63
+
64
+ console.print("\n")
65
+ console.print(table)
66
+ console.print("\n[dim]connect:[/] [bold]son connect <hostname|ip>[/]")
son/cli/main.py ADDED
@@ -0,0 +1,19 @@
1
+ import typer
2
+
3
+ from son.cli import discover, connect, tunnel, web
4
+
5
+ app = typer.Typer(
6
+ name="son",
7
+ help="Superior Organised Network - remote access tool",
8
+ no_args_is_help=True,
9
+ )
10
+
11
+ app.add_typer(discover.app, name="discover")
12
+ app.add_typer(connect.app, name="connect")
13
+ app.add_typer(tunnel.app, name="tunnel")
14
+ app.add_typer(web.app, name="web")
15
+
16
+
17
+ @app.callback()
18
+ def callback():
19
+ pass
son/cli/tunnel.py ADDED
@@ -0,0 +1,76 @@
1
+ import asyncio
2
+ import re
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ from son.client.connector import SonConnector
10
+ from son.client.tunnel import TunnelManager
11
+ from son.core.protocol import CONNECTION_PORT
12
+
13
+ app = typer.Typer(help="Create tunnels through a sond agent")
14
+ console = Console()
15
+
16
+ LOCAL_FORWARD_RE = re.compile(r"^(?:-L\s+)?(\d+):([^:]+):(\d+)$")
17
+
18
+
19
+ @app.callback(invoke_without_command=True)
20
+ def tunnel(
21
+ host: str = typer.Argument(..., help="sond hostname or IP"),
22
+ port: int = typer.Option(CONNECTION_PORT, "--port", "-p", help="sond port"),
23
+ password: str = typer.Option("", "--password", "-P", help="auth password"),
24
+ local: Optional[str] = typer.Option(
25
+ None, "--local", "-L",
26
+ help="local forward: local_port:remote_host:remote_port",
27
+ ),
28
+ ):
29
+ if not local:
30
+ console.print("[red]specify a tunnel with --local (-L), e.g. -L 8080:localhost:80[/]")
31
+ raise typer.Exit(1)
32
+
33
+ m = LOCAL_FORWARD_RE.match(local)
34
+ if not m:
35
+ console.print("[red]invalid format, use: local_port:remote_host:remote_port[/]")
36
+ raise typer.Exit(1)
37
+
38
+ local_port = int(m.group(1))
39
+ remote_host = m.group(2)
40
+ remote_port = int(m.group(3))
41
+
42
+ async def run():
43
+ connector = SonConnector(host, port, password)
44
+ try:
45
+ await connector.connect()
46
+ except Exception as e:
47
+ console.print(f"[red]connection failed:[/] {e}")
48
+ raise typer.Exit(1)
49
+
50
+ mgr = TunnelManager(connector)
51
+
52
+ await mgr.listen("127.0.0.1", local_port, remote_host, remote_port)
53
+
54
+ console.print(Panel(
55
+ f"[bold green]tunnel active[/]\n"
56
+ f" [cyan]127.0.0.1:{local_port}[/] → [yellow]{remote_host}:{remote_port}[/] via [bold]{host}[/]",
57
+ border_style="green",
58
+ ))
59
+ console.print("[dim]press Ctrl+C to stop[/]\n")
60
+
61
+ listen_task = asyncio.create_task(connector.listen())
62
+
63
+ try:
64
+ await asyncio.Event().wait()
65
+ except (asyncio.CancelledError, KeyboardInterrupt):
66
+ pass
67
+ finally:
68
+ await mgr.stop_all()
69
+ listen_task.cancel()
70
+ await connector.disconnect()
71
+ console.print("[red]tunnel closed[/]")
72
+
73
+ try:
74
+ asyncio.run(run())
75
+ except KeyboardInterrupt:
76
+ pass
son/cli/web.py ADDED
@@ -0,0 +1,15 @@
1
+ import typer
2
+ from rich.console import Console
3
+
4
+ app = typer.Typer(help="Start the local web UI")
5
+ console = Console()
6
+
7
+
8
+ @app.callback(invoke_without_command=True)
9
+ def web(
10
+ port: int = typer.Option(8585, "--port", "-p", help="web UI port"),
11
+ host: str = typer.Option("127.0.0.1", "--host", "-H", help="bind address"),
12
+ ):
13
+ from son.web.server import run
14
+ console.print(f"[green]starting web UI at [bold]http://{host}:{port}[/][/]")
15
+ run(host=host, port=port)
son/client/__init__.py ADDED
File without changes
@@ -0,0 +1,125 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from typing import Callable, Optional
5
+
6
+ import websockets
7
+
8
+ from son.core.protocol import (
9
+ WS_OPEN, WS_INPUT, WS_OUTPUT, WS_RESIZE, WS_CLOSE,
10
+ WS_AUTH, WS_AUTH_OK, WS_AUTH_FAIL,
11
+ WS_TUNNEL_OPEN, WS_TUNNEL_DATA, WS_TUNNEL_CLOSE,
12
+ )
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ class SonConnector:
18
+ def __init__(self, host: str, port: int, password: str = ""):
19
+ self._host = host
20
+ self._port = port
21
+ self._password = password
22
+ self._ws = None
23
+ self._shell_open = False
24
+ self._on_output: Optional[Callable] = None
25
+ self._on_disconnect: Optional[Callable] = None
26
+ self._on_tunnel_data: Optional[Callable] = None
27
+ self._on_tunnel_close: Optional[Callable] = None
28
+
29
+ @property
30
+ def connected(self) -> bool:
31
+ return self._ws is not None and not self._ws.close_code
32
+
33
+ def on_output(self, callback: Callable):
34
+ self._on_output = callback
35
+
36
+ def on_disconnect(self, callback: Callable):
37
+ self._on_disconnect = callback
38
+
39
+ def on_tunnel_data(self, callback: Callable):
40
+ self._on_tunnel_data = callback
41
+
42
+ def on_tunnel_close(self, callback: Callable):
43
+ self._on_tunnel_close = callback
44
+
45
+ async def connect(self):
46
+ uri = f"ws://{self._host}:{self._port}"
47
+ self._ws = await websockets.connect(uri)
48
+ if self._password:
49
+ await self._send({"type": WS_AUTH, "password": self._password})
50
+ resp = json.loads(await self._ws.recv())
51
+ if resp.get("type") == WS_AUTH_FAIL:
52
+ raise PermissionError("authentication failed")
53
+ log.info("connected to %s:%s", self._host, self._port)
54
+ return self
55
+
56
+ async def open_shell(self, shell: str = "", cols: int = 120, rows: int = 30):
57
+ await self._send({"type": WS_OPEN, "shell": shell, "cols": cols, "rows": rows})
58
+ self._shell_open = True
59
+
60
+ async def send_input(self, data: str):
61
+ if self._shell_open:
62
+ await self._send({"type": WS_INPUT, "data": data})
63
+
64
+ async def resize(self, cols: int, rows: int):
65
+ if self._shell_open:
66
+ await self._send({"type": WS_RESIZE, "cols": cols, "rows": rows})
67
+
68
+ async def close_shell(self):
69
+ if self._shell_open:
70
+ await self._send({"type": WS_CLOSE})
71
+ self._shell_open = False
72
+
73
+ async def tunnel_open(self, remote_host: str, remote_port: int, tunnel_id: int):
74
+ await self._send({
75
+ "type": WS_TUNNEL_OPEN,
76
+ "tunnel_id": tunnel_id,
77
+ "remote_host": remote_host,
78
+ "remote_port": remote_port,
79
+ })
80
+
81
+ async def tunnel_send(self, tunnel_id: int, data: str):
82
+ await self._send({
83
+ "type": WS_TUNNEL_DATA,
84
+ "tunnel_id": tunnel_id,
85
+ "data": data,
86
+ })
87
+
88
+ async def tunnel_close(self, tunnel_id: int):
89
+ await self._send({"type": WS_TUNNEL_CLOSE, "tunnel_id": tunnel_id})
90
+
91
+ async def _send(self, msg: dict):
92
+ if self._ws:
93
+ try:
94
+ await self._ws.send(json.dumps(msg))
95
+ except websockets.exceptions.ConnectionClosed:
96
+ pass
97
+
98
+ async def listen(self):
99
+ try:
100
+ async for raw in self._ws:
101
+ msg = json.loads(raw)
102
+ msg_type = msg.get("type")
103
+ if msg_type == WS_OUTPUT:
104
+ if self._on_output:
105
+ self._on_output(msg.get("data", ""))
106
+ elif msg_type == WS_TUNNEL_DATA:
107
+ if self._on_tunnel_data:
108
+ self._on_tunnel_data(msg.get("tunnel_id"), msg.get("data", ""))
109
+ elif msg_type == WS_TUNNEL_CLOSE:
110
+ if self._on_tunnel_close:
111
+ self._on_tunnel_close(msg.get("tunnel_id"))
112
+ elif msg_type == WS_CLOSE:
113
+ self._shell_open = False
114
+ break
115
+ except (websockets.exceptions.ConnectionClosed, asyncio.CancelledError):
116
+ pass
117
+ finally:
118
+ if self._on_disconnect:
119
+ self._on_disconnect()
120
+
121
+ async def disconnect(self):
122
+ await self.close_shell()
123
+ if self._ws:
124
+ await self._ws.close()
125
+ log.info("disconnected")
@@ -0,0 +1,72 @@
1
+ import asyncio
2
+ import socket
3
+ import logging
4
+ from typing import Callable
5
+
6
+ from son.core.protocol import DISCOVERY_PORT, DISCOVERY_MAGIC
7
+ from son.core.types import DiscoveryInfo
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ class DiscoveryListener:
13
+ def __init__(self):
14
+ self._running = False
15
+ self._on_discover: Callable[[DiscoveryInfo], None] | None = None
16
+ self._seen: set[str] = set()
17
+
18
+ def on_discover(self, callback: Callable[[DiscoveryInfo], None]):
19
+ self._on_discover = callback
20
+
21
+ async def start(self):
22
+ self._running = True
23
+ loop = asyncio.get_running_loop()
24
+ transport: asyncio.DatagramTransport | None = None
25
+
26
+ class Proto(asyncio.DatagramProtocol):
27
+ def connection_made(self, t):
28
+ nonlocal transport
29
+ transport = t
30
+ sock = t.get_extra_info("socket")
31
+ if sock is not None:
32
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
33
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
34
+
35
+ def datagram_received(self, data: bytes, addr):
36
+ if not data.startswith(DISCOVERY_MAGIC):
37
+ return
38
+ payload = data[len(DISCOVERY_MAGIC) + 1:]
39
+ try:
40
+ info = DiscoveryInfo.from_bytes(payload)
41
+ key = f"{info.ip}:{info.port}"
42
+ if key not in self._seen:
43
+ self._seen.add(key)
44
+ log.debug("discovered: %s", info)
45
+ if self._on_discover:
46
+ self._on_discover(info)
47
+ except Exception as e:
48
+ log.debug("bad discovery packet from %s: %s", addr, e)
49
+
50
+ def error_received(self, exc):
51
+ log.warning("discovery listener error: %s", exc)
52
+
53
+ try:
54
+ _, _ = await loop.create_datagram_endpoint(
55
+ Proto,
56
+ local_addr=("0.0.0.0", DISCOVERY_PORT),
57
+ allow_broadcast=True,
58
+ )
59
+ log.info("discovery listener started on port %s", DISCOVERY_PORT)
60
+ while self._running:
61
+ await asyncio.sleep(1)
62
+ except asyncio.CancelledError:
63
+ pass
64
+ finally:
65
+ if transport:
66
+ transport.close()
67
+
68
+ def stop(self):
69
+ self._running = False
70
+
71
+ def reset_seen(self):
72
+ self._seen.clear()
son/client/tunnel.py ADDED
@@ -0,0 +1,113 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Optional
4
+
5
+ from son.client.connector import SonConnector
6
+
7
+ log = logging.getLogger(__name__)
8
+
9
+
10
+ class TunnelEndpoint:
11
+ def __init__(self, conn_id: int, remote_host: str, remote_port: int,
12
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
13
+ self._conn_id = conn_id
14
+ self._remote_host = remote_host
15
+ self._remote_port = remote_port
16
+ self._reader = reader
17
+ self._writer = writer
18
+ self._connector: Optional[SonConnector] = None
19
+ self._reader_task: Optional[asyncio.Task] = None
20
+ self._closed = False
21
+
22
+ def start(self, connector: SonConnector):
23
+ self._connector = connector
24
+ self._reader_task = asyncio.create_task(self._read_local())
25
+ asyncio.create_task(
26
+ connector.tunnel_open(self._remote_host, self._remote_port, self._conn_id)
27
+ )
28
+
29
+ async def _read_local(self):
30
+ try:
31
+ while not self._closed:
32
+ data = await self._reader.read(65536)
33
+ if not data:
34
+ break
35
+ await self._connector.tunnel_send(
36
+ self._conn_id,
37
+ data.decode("utf-8", errors="replace"),
38
+ )
39
+ except (asyncio.CancelledError, Exception):
40
+ pass
41
+ finally:
42
+ await self._close()
43
+
44
+ async def write_remote(self, data: str):
45
+ if self._closed:
46
+ return
47
+ try:
48
+ self._writer.write(data.encode("utf-8"))
49
+ await self._writer.drain()
50
+ except Exception:
51
+ await self._close()
52
+
53
+ async def _close(self):
54
+ if self._closed:
55
+ return
56
+ self._closed = True
57
+ if self._reader_task:
58
+ self._reader_task.cancel()
59
+ try:
60
+ self._writer.close()
61
+ except Exception:
62
+ pass
63
+ await self._connector.tunnel_close(self._conn_id)
64
+
65
+ async def close(self):
66
+ await self._close()
67
+
68
+
69
+ class TunnelManager:
70
+ def __init__(self, connector: SonConnector):
71
+ self._connector = connector
72
+ self._servers: list[asyncio.Server] = []
73
+ self._endpoints: dict[int, TunnelEndpoint] = {}
74
+ self._next_conn_id = 0
75
+ self._connector.on_tunnel_data(self._on_tunnel_data)
76
+ self._connector.on_tunnel_close(self._on_tunnel_close)
77
+
78
+ async def listen(
79
+ self,
80
+ local_host: str,
81
+ local_port: int,
82
+ remote_host: str,
83
+ remote_port: int,
84
+ ):
85
+ async def on_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
86
+ conn_id = self._next_conn_id
87
+ self._next_conn_id += 1
88
+ ep = TunnelEndpoint(conn_id, remote_host, remote_port, reader, writer)
89
+ self._endpoints[conn_id] = ep
90
+ ep.start(self._connector)
91
+
92
+ server = await asyncio.start_server(on_connect, host=local_host, port=local_port)
93
+ self._servers.append(server)
94
+ log.info("listening on %s:%s -> remote %s:%s", local_host, local_port, remote_host, remote_port)
95
+
96
+ def _on_tunnel_data(self, tunnel_id: int, data: str):
97
+ ep = self._endpoints.get(tunnel_id)
98
+ if ep:
99
+ asyncio.create_task(ep.write_remote(data))
100
+
101
+ def _on_tunnel_close(self, tunnel_id: int):
102
+ ep = self._endpoints.pop(tunnel_id, None)
103
+ if ep:
104
+ asyncio.create_task(ep.close())
105
+
106
+ async def stop_all(self):
107
+ for s in self._servers:
108
+ s.close()
109
+ await s.wait_closed()
110
+ self._servers.clear()
111
+ for ep in list(self._endpoints.values()):
112
+ await ep.close()
113
+ self._endpoints.clear()
son/core/__init__.py ADDED
File without changes
son/core/protocol.py ADDED
@@ -0,0 +1,21 @@
1
+ DISCOVERY_PORT = 8484
2
+ CONNECTION_PORT = 9443
3
+ DISCOVERY_INTERVAL = 2.0
4
+ DISCOVERY_MAGIC = b"SONDISCOVERY"
5
+
6
+ SHELL_BUFFER_SIZE = 65536
7
+
8
+ WS_OPEN = "open"
9
+ WS_INPUT = "input"
10
+ WS_OUTPUT = "output"
11
+ WS_RESIZE = "resize"
12
+ WS_CLOSE = "close"
13
+ WS_ERROR = "error"
14
+ WS_TUNNEL_OPEN = "tunnel_open"
15
+ WS_TUNNEL_DATA = "tunnel_data"
16
+ WS_TUNNEL_CLOSE = "tunnel_close"
17
+ WS_AUTH = "auth"
18
+ WS_AUTH_OK = "auth_ok"
19
+ WS_AUTH_FAIL = "auth_fail"
20
+ WS_PING = "ping"
21
+ WS_PONG = "pong"
son/core/types.py ADDED
@@ -0,0 +1,58 @@
1
+ from dataclasses import dataclass, field, asdict
2
+ from typing import Optional
3
+ import json
4
+ import socket
5
+
6
+
7
+ @dataclass
8
+ class DiscoveryInfo:
9
+ hostname: str
10
+ ip: str
11
+ port: int
12
+ version: str
13
+ os: str
14
+ shells: list[str] = field(default_factory=lambda: ["powershell", "cmd"])
15
+
16
+ def to_json(self) -> str:
17
+ return json.dumps(asdict(self))
18
+
19
+ @classmethod
20
+ def from_json(cls, data: str) -> "DiscoveryInfo":
21
+ d = json.loads(data)
22
+ return cls(**d)
23
+
24
+ def to_bytes(self) -> bytes:
25
+ return self.to_json().encode("utf-8")
26
+
27
+ @classmethod
28
+ def from_bytes(cls, data: bytes) -> "DiscoveryInfo":
29
+ return cls.from_json(data.decode("utf-8"))
30
+
31
+
32
+ @dataclass
33
+ class ShellConfig:
34
+ shell: str = "powershell"
35
+ cols: int = 120
36
+ rows: int = 30
37
+
38
+
39
+ def get_default_shell() -> str:
40
+ import sys
41
+ if sys.platform == "win32":
42
+ return "powershell"
43
+ return "bash"
44
+
45
+
46
+ def get_hostname() -> str:
47
+ return socket.gethostname()
48
+
49
+
50
+ def get_local_ip() -> str:
51
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
52
+ try:
53
+ s.connect(("10.255.255.255", 1))
54
+ return s.getsockname()[0]
55
+ except Exception:
56
+ return "127.0.0.1"
57
+ finally:
58
+ s.close()
son/daemon/__init__.py ADDED
File without changes