sonx 0.1.0__tar.gz
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.
- sonx-0.1.0/PKG-INFO +87 -0
- sonx-0.1.0/README.md +56 -0
- sonx-0.1.0/pyproject.toml +46 -0
- sonx-0.1.0/setup.cfg +4 -0
- sonx-0.1.0/son/__init__.py +0 -0
- sonx-0.1.0/son/__main__.py +3 -0
- sonx-0.1.0/son/cli/__init__.py +0 -0
- sonx-0.1.0/son/cli/connect.py +77 -0
- sonx-0.1.0/son/cli/discover.py +66 -0
- sonx-0.1.0/son/cli/main.py +19 -0
- sonx-0.1.0/son/cli/tunnel.py +76 -0
- sonx-0.1.0/son/cli/web.py +15 -0
- sonx-0.1.0/son/client/__init__.py +0 -0
- sonx-0.1.0/son/client/connector.py +125 -0
- sonx-0.1.0/son/client/discovery.py +72 -0
- sonx-0.1.0/son/client/tunnel.py +113 -0
- sonx-0.1.0/son/core/__init__.py +0 -0
- sonx-0.1.0/son/core/protocol.py +21 -0
- sonx-0.1.0/son/core/types.py +58 -0
- sonx-0.1.0/son/daemon/__init__.py +0 -0
- sonx-0.1.0/son/daemon/broadcast.py +79 -0
- sonx-0.1.0/son/daemon/main.py +69 -0
- sonx-0.1.0/son/daemon/server.py +215 -0
- sonx-0.1.0/son/daemon/shell.py +90 -0
- sonx-0.1.0/son/web/__init__.py +0 -0
- sonx-0.1.0/son/web/server.py +91 -0
- sonx-0.1.0/sond/__init__.py +0 -0
- sonx-0.1.0/sond/__main__.py +3 -0
- sonx-0.1.0/sonx.egg-info/PKG-INFO +87 -0
- sonx-0.1.0/sonx.egg-info/SOURCES.txt +32 -0
- sonx-0.1.0/sonx.egg-info/dependency_links.txt +1 -0
- sonx-0.1.0/sonx.egg-info/entry_points.txt +3 -0
- sonx-0.1.0/sonx.egg-info/requires.txt +8 -0
- sonx-0.1.0/sonx.egg-info/top_level.txt +2 -0
sonx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Superior Organised Network - Remote access tool
|
|
5
|
+
Author: son
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/randompasswordis7/son
|
|
8
|
+
Project-URL: Source, https://github.com/randompasswordis7/son
|
|
9
|
+
Keywords: remote-access,ssh,tunnel,terminal,network
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: System :: Networking
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: typer>=0.12.0
|
|
25
|
+
Requires-Dist: rich>=13.0.0
|
|
26
|
+
Requires-Dist: websockets>=12.0
|
|
27
|
+
Provides-Extra: web
|
|
28
|
+
Requires-Dist: fastapi>=0.110.0; extra == "web"
|
|
29
|
+
Requires-Dist: uvicorn>=0.27.0; extra == "web"
|
|
30
|
+
Requires-Dist: httpx>=0.27.0; extra == "web"
|
|
31
|
+
|
|
32
|
+
# son — Superior Organised Network
|
|
33
|
+
|
|
34
|
+
Remote access tool with a daemon-agent architecture. Run `sond` on target machines, connect via `son` CLI or web UI.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install son
|
|
40
|
+
# or with web UI support:
|
|
41
|
+
pip install "son[web]"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
**On the target machine** (the one you want to access):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sond
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**On your machine** (the one you connect from):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Discover sond instances on LAN
|
|
56
|
+
son discover
|
|
57
|
+
|
|
58
|
+
# Connect to a remote shell
|
|
59
|
+
son connect 192.168.1.50
|
|
60
|
+
|
|
61
|
+
# Tunnel a local port through the target
|
|
62
|
+
son tunnel 192.168.1.50 -L 8080:localhost:80
|
|
63
|
+
|
|
64
|
+
# Web terminal UI
|
|
65
|
+
son web
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## How it works
|
|
69
|
+
|
|
70
|
+
| Component | Runs on | Purpose |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `sond` | Target machine | UDP broadcast (LAN discovery), WebSocket server for shell + tunnels |
|
|
73
|
+
| `son` | Your machine | CLI + web UI, connects to `sond` instances |
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `sond` | Start daemon (`-p` port, `--password`, `--no-broadcast`) |
|
|
80
|
+
| `son discover` | Scan LAN for sond instances |
|
|
81
|
+
| `son connect <host>` | Open remote shell (`-s powershell/bash/cmd`) |
|
|
82
|
+
| `son tunnel <host> -L local:remote:port` | Local port forwarding |
|
|
83
|
+
| `son web` | Browser-based terminal at `http://127.0.0.1:8585` |
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
sonx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# son — Superior Organised Network
|
|
2
|
+
|
|
3
|
+
Remote access tool with a daemon-agent architecture. Run `sond` on target machines, connect via `son` CLI or web UI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install son
|
|
9
|
+
# or with web UI support:
|
|
10
|
+
pip install "son[web]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
**On the target machine** (the one you want to access):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
sond
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**On your machine** (the one you connect from):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Discover sond instances on LAN
|
|
25
|
+
son discover
|
|
26
|
+
|
|
27
|
+
# Connect to a remote shell
|
|
28
|
+
son connect 192.168.1.50
|
|
29
|
+
|
|
30
|
+
# Tunnel a local port through the target
|
|
31
|
+
son tunnel 192.168.1.50 -L 8080:localhost:80
|
|
32
|
+
|
|
33
|
+
# Web terminal UI
|
|
34
|
+
son web
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
| Component | Runs on | Purpose |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `sond` | Target machine | UDP broadcast (LAN discovery), WebSocket server for shell + tunnels |
|
|
42
|
+
| `son` | Your machine | CLI + web UI, connects to `sond` instances |
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `sond` | Start daemon (`-p` port, `--password`, `--no-broadcast`) |
|
|
49
|
+
| `son discover` | Scan LAN for sond instances |
|
|
50
|
+
| `son connect <host>` | Open remote shell (`-s powershell/bash/cmd`) |
|
|
51
|
+
| `son tunnel <host> -L local:remote:port` | Local port forwarding |
|
|
52
|
+
| `son web` | Browser-based terminal at `http://127.0.0.1:8585` |
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sonx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Superior Organised Network - Remote access tool"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [{name = "son"}]
|
|
9
|
+
keywords = ["remote-access", "ssh", "tunnel", "terminal", "network"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: System Administrators",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: System :: Networking",
|
|
22
|
+
"Topic :: System :: Systems Administration",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"typer>=0.12.0",
|
|
26
|
+
"rich>=13.0.0",
|
|
27
|
+
"websockets>=12.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
web = ["fastapi>=0.110.0", "uvicorn>=0.27.0", "httpx>=0.27.0"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/randompasswordis7/son"
|
|
35
|
+
Source = "https://github.com/randompasswordis7/son"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
son = "son.cli.main:app"
|
|
39
|
+
sond = "son.daemon.main:main"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["setuptools>=68.0"]
|
|
43
|
+
build-backend = "setuptools.build_meta"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
include = ["son*", "sond*"]
|
sonx-0.1.0/setup.cfg
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -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
|
|
@@ -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>[/]")
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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)
|
|
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()
|