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 +0 -0
- son/__main__.py +3 -0
- son/cli/__init__.py +0 -0
- son/cli/connect.py +77 -0
- son/cli/discover.py +66 -0
- son/cli/main.py +19 -0
- son/cli/tunnel.py +76 -0
- son/cli/web.py +15 -0
- son/client/__init__.py +0 -0
- son/client/connector.py +125 -0
- son/client/discovery.py +72 -0
- son/client/tunnel.py +113 -0
- son/core/__init__.py +0 -0
- son/core/protocol.py +21 -0
- son/core/types.py +58 -0
- son/daemon/__init__.py +0 -0
- son/daemon/broadcast.py +79 -0
- son/daemon/main.py +69 -0
- son/daemon/server.py +215 -0
- son/daemon/shell.py +90 -0
- son/web/__init__.py +0 -0
- son/web/server.py +91 -0
- sond/__init__.py +0 -0
- sond/__main__.py +3 -0
- sonx-0.1.0.dist-info/METADATA +87 -0
- sonx-0.1.0.dist-info/RECORD +29 -0
- sonx-0.1.0.dist-info/WHEEL +5 -0
- sonx-0.1.0.dist-info/entry_points.txt +3 -0
- sonx-0.1.0.dist-info/top_level.txt +2 -0
son/__init__.py
ADDED
|
File without changes
|
son/__main__.py
ADDED
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
|
son/client/connector.py
ADDED
|
@@ -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")
|
son/client/discovery.py
ADDED
|
@@ -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
|