rimokon 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.
- rimokon-0.1.0/.gitignore +5 -0
- rimokon-0.1.0/PKG-INFO +29 -0
- rimokon-0.1.0/README.md +17 -0
- rimokon-0.1.0/pyproject.toml +23 -0
- rimokon-0.1.0/src/rimokon/__init__.py +1 -0
- rimokon-0.1.0/src/rimokon/__main__.py +6 -0
- rimokon-0.1.0/src/rimokon/cli.py +140 -0
- rimokon-0.1.0/src/rimokon/controller.py +68 -0
- rimokon-0.1.0/src/rimokon/discovery.py +92 -0
- rimokon-0.1.0/src/rimokon/protocol.py +78 -0
rimokon-0.1.0/.gitignore
ADDED
rimokon-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rimokon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LAN remote-shell controller for rimokond.
|
|
5
|
+
Author: prade
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: controller,lan,remote,shell
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# rimokon
|
|
14
|
+
|
|
15
|
+
LAN remote-shell controller for **rimokond**. Runs on the one device you control from.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
rimokon list
|
|
21
|
+
rimokon shell <peer> [<key> | /X]
|
|
22
|
+
rimokon run <peer> [<key> | /X] "cmd"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `list` — discover and show peers on the LAN.
|
|
26
|
+
- `shell <peer>` — interactive per-line session with live output.
|
|
27
|
+
- `run <peer> "cmd"` — run a single command and stream its output.
|
|
28
|
+
|
|
29
|
+
`<peer>` is the name set during `rimokond setup`. The optional argument after the peer is the value configured on that device; `/X` is an alternate form. If it is omitted or does not match, the controller reports `could not connect`.
|
rimokon-0.1.0/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# rimokon
|
|
2
|
+
|
|
3
|
+
LAN remote-shell controller for **rimokond**. Runs on the one device you control from.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
rimokon list
|
|
9
|
+
rimokon shell <peer> [<key> | /X]
|
|
10
|
+
rimokon run <peer> [<key> | /X] "cmd"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `list` — discover and show peers on the LAN.
|
|
14
|
+
- `shell <peer>` — interactive per-line session with live output.
|
|
15
|
+
- `run <peer> "cmd"` — run a single command and stream its output.
|
|
16
|
+
|
|
17
|
+
`<peer>` is the name set during `rimokond setup`. The optional argument after the peer is the value configured on that device; `/X` is an alternate form. If it is omitted or does not match, the controller reports `could not connect`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rimokon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LAN remote-shell controller for rimokond."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "prade" }]
|
|
13
|
+
keywords = ["lan", "remote", "shell", "controller"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
rimokon = "rimokon.__main__:main"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/rimokon"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""rimokon CLI: list | shell <peer> [secret] | run <peer> [secret] "cmd"."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .discovery import Discovery
|
|
8
|
+
from .controller import connect, authenticate, run_command
|
|
9
|
+
from .protocol import send_ctrl, FRAME_STDIN
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _resolve(disc, name):
|
|
13
|
+
disc.query()
|
|
14
|
+
time.sleep(1.5)
|
|
15
|
+
return disc.find(name)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write(data, is_err):
|
|
19
|
+
buf = (sys.stderr.buffer if is_err else sys.stdout.buffer)
|
|
20
|
+
try:
|
|
21
|
+
buf.write(data)
|
|
22
|
+
buf.flush()
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def do_list(disc):
|
|
28
|
+
disc.query()
|
|
29
|
+
time.sleep(2)
|
|
30
|
+
peers = disc.list_peers()
|
|
31
|
+
if not peers:
|
|
32
|
+
print("No peers found on the LAN.")
|
|
33
|
+
return
|
|
34
|
+
print("%-4s %-22s %-16s %s" % ("#", "PEERNAME", "IP", "PORT"))
|
|
35
|
+
for i, p in enumerate(peers):
|
|
36
|
+
print("%-4d %-22s %-16s %s" % (i, p.peername, p.ip, p.port))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def do_shell(disc, args):
|
|
40
|
+
if not args:
|
|
41
|
+
print("usage: rimokon shell <peer> [<peerpass> | /X]")
|
|
42
|
+
return
|
|
43
|
+
peername = args[0]
|
|
44
|
+
secret = args[1] if len(args) > 1 else None
|
|
45
|
+
peer = _resolve(disc, peername)
|
|
46
|
+
if not peer:
|
|
47
|
+
print("could not connect")
|
|
48
|
+
return
|
|
49
|
+
sock = connect(peer)
|
|
50
|
+
reader, err = authenticate(sock, secret)
|
|
51
|
+
if err:
|
|
52
|
+
print(err)
|
|
53
|
+
sock.close()
|
|
54
|
+
return
|
|
55
|
+
print("connected to %s (%s). type 'exit' to quit." % (peer.peername, peer.ip))
|
|
56
|
+
try:
|
|
57
|
+
while True:
|
|
58
|
+
try:
|
|
59
|
+
line = input("%s> " % peer.peername)
|
|
60
|
+
except EOFError:
|
|
61
|
+
break
|
|
62
|
+
line = line.rstrip("\n")
|
|
63
|
+
if not line:
|
|
64
|
+
continue
|
|
65
|
+
if line in ("exit", "quit"):
|
|
66
|
+
break
|
|
67
|
+
try:
|
|
68
|
+
run_command(
|
|
69
|
+
sock, reader, line,
|
|
70
|
+
on_output=lambda d, e: _write(d, e),
|
|
71
|
+
on_exit=lambda c: None,
|
|
72
|
+
)
|
|
73
|
+
sys.stdout.write("\n")
|
|
74
|
+
sys.stdout.flush()
|
|
75
|
+
except KeyboardInterrupt:
|
|
76
|
+
send_ctrl(sock, action="stop")
|
|
77
|
+
print("\n(stop sent)")
|
|
78
|
+
except (KeyboardInterrupt, EOFError):
|
|
79
|
+
pass
|
|
80
|
+
sock.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def do_run(disc, args):
|
|
84
|
+
if len(args) < 2:
|
|
85
|
+
print('usage: rimokon run <peer> [<peerpass> | /X] "cmd"')
|
|
86
|
+
return
|
|
87
|
+
peername = args[0]
|
|
88
|
+
rest = args[1:]
|
|
89
|
+
if len(rest) >= 2:
|
|
90
|
+
secret = rest[0]
|
|
91
|
+
command = " ".join(rest[1:])
|
|
92
|
+
else:
|
|
93
|
+
secret = None
|
|
94
|
+
command = rest[0]
|
|
95
|
+
peer = _resolve(disc, peername)
|
|
96
|
+
if not peer:
|
|
97
|
+
print("could not connect")
|
|
98
|
+
return
|
|
99
|
+
sock = connect(peer)
|
|
100
|
+
reader, err = authenticate(sock, secret)
|
|
101
|
+
if err:
|
|
102
|
+
print(err)
|
|
103
|
+
sock.close()
|
|
104
|
+
return
|
|
105
|
+
exit_code = {"code": -1}
|
|
106
|
+
|
|
107
|
+
def on_exit(code):
|
|
108
|
+
exit_code["code"] = code
|
|
109
|
+
|
|
110
|
+
run_command(sock, reader, command, on_output=_write, on_exit=on_exit)
|
|
111
|
+
sock.close()
|
|
112
|
+
sys.exit(exit_code["code"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
argv = sys.argv[1:]
|
|
117
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
118
|
+
print("rimokon - LAN remote shell controller")
|
|
119
|
+
print(" rimokon list")
|
|
120
|
+
print(" rimokon shell <peer> [<peerpass> | /X]")
|
|
121
|
+
print(' rimokon run <peer> [<peerpass> | /X] "cmd"')
|
|
122
|
+
return
|
|
123
|
+
cmd = argv[0]
|
|
124
|
+
disc = Discovery()
|
|
125
|
+
disc.start()
|
|
126
|
+
try:
|
|
127
|
+
if cmd == "list":
|
|
128
|
+
do_list(disc)
|
|
129
|
+
elif cmd == "shell":
|
|
130
|
+
do_shell(disc, argv[1:])
|
|
131
|
+
elif cmd == "run":
|
|
132
|
+
do_run(disc, argv[1:])
|
|
133
|
+
else:
|
|
134
|
+
print("unknown command:", cmd)
|
|
135
|
+
finally:
|
|
136
|
+
disc.stop()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Controller-side connection: connect, authenticate, run commands."""
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
from .protocol import (
|
|
6
|
+
FRAME_CTRL,
|
|
7
|
+
FRAME_STDOUT,
|
|
8
|
+
FRAME_STDERR,
|
|
9
|
+
FrameReader,
|
|
10
|
+
FrameError,
|
|
11
|
+
send_ctrl,
|
|
12
|
+
parse_ctrl,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def connect(peer, timeout=5):
|
|
17
|
+
sock = socket.create_connection((peer.ip, peer.port), timeout=timeout)
|
|
18
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
19
|
+
return sock
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def authenticate(sock, secret):
|
|
23
|
+
"""Returns (FrameReader, error). On success error is None."""
|
|
24
|
+
send_ctrl(sock, action="hello", secret=secret)
|
|
25
|
+
reader = FrameReader(sock)
|
|
26
|
+
try:
|
|
27
|
+
frame = reader.read_frame()
|
|
28
|
+
except FrameError:
|
|
29
|
+
return None, "could not connect"
|
|
30
|
+
if frame is None:
|
|
31
|
+
return None, "could not connect"
|
|
32
|
+
ftype, payload = frame
|
|
33
|
+
if ftype != FRAME_CTRL:
|
|
34
|
+
return None, "could not connect"
|
|
35
|
+
msg = parse_ctrl(payload)
|
|
36
|
+
if msg.get("action") == "error":
|
|
37
|
+
return None, "could not connect"
|
|
38
|
+
return reader, None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_command(sock, reader, cmd, on_output, on_exit):
|
|
42
|
+
"""Send a run request and stream output until the command exits."""
|
|
43
|
+
send_ctrl(sock, action="run", cmd=cmd)
|
|
44
|
+
try:
|
|
45
|
+
while True:
|
|
46
|
+
frame = reader.read_frame()
|
|
47
|
+
if frame is None:
|
|
48
|
+
on_exit(-1)
|
|
49
|
+
return
|
|
50
|
+
ftype, payload = frame
|
|
51
|
+
if ftype == FRAME_STDOUT:
|
|
52
|
+
on_output(payload, False)
|
|
53
|
+
elif ftype == FRAME_STDERR:
|
|
54
|
+
on_output(payload, True)
|
|
55
|
+
elif ftype == FRAME_CTRL:
|
|
56
|
+
msg = parse_ctrl(payload)
|
|
57
|
+
action = msg.get("action")
|
|
58
|
+
if action == "exit":
|
|
59
|
+
on_exit(msg.get("code", 0))
|
|
60
|
+
return
|
|
61
|
+
if action == "error":
|
|
62
|
+
on_exit(-1)
|
|
63
|
+
return
|
|
64
|
+
else:
|
|
65
|
+
on_exit(-1)
|
|
66
|
+
return
|
|
67
|
+
except FrameError:
|
|
68
|
+
on_exit(-1)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""UDP LAN discovery client: send queries, listen for beacons, track peers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .protocol import DEFAULT_DISC_PORT
|
|
9
|
+
|
|
10
|
+
BEACON_TTL = 12
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Peer:
|
|
14
|
+
def __init__(self, peername, peer_id, ip, port):
|
|
15
|
+
self.peername = peername
|
|
16
|
+
self.peer_id = peer_id
|
|
17
|
+
self.ip = ip
|
|
18
|
+
self.port = port
|
|
19
|
+
self.last_seen = time.time()
|
|
20
|
+
|
|
21
|
+
def alive(self):
|
|
22
|
+
return time.time() - self.last_seen < BEACON_TTL
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Discovery:
|
|
26
|
+
def __init__(self, disc_port=DEFAULT_DISC_PORT):
|
|
27
|
+
self.disc_port = disc_port
|
|
28
|
+
self.peers = {}
|
|
29
|
+
# Do NOT bind to disc_port: use an ephemeral source port so the
|
|
30
|
+
# daemon's unicast query-reply reaches us even when the daemon itself
|
|
31
|
+
# holds the discovery port on the same machine.
|
|
32
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
33
|
+
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
34
|
+
self.sock.settimeout(0.3)
|
|
35
|
+
self._stop = threading.Event()
|
|
36
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
37
|
+
|
|
38
|
+
def start(self):
|
|
39
|
+
self._thread.start()
|
|
40
|
+
|
|
41
|
+
def stop(self):
|
|
42
|
+
self._stop.set()
|
|
43
|
+
|
|
44
|
+
def query(self):
|
|
45
|
+
# Broadcast for LAN peers, plus loopback so a daemon on the same
|
|
46
|
+
# machine is discoverable (it replies via unicast to our ephemeral port).
|
|
47
|
+
for target in ("255.255.255.255", "127.0.0.1"):
|
|
48
|
+
try:
|
|
49
|
+
self.sock.sendto(
|
|
50
|
+
json.dumps({"type": "query"}).encode(),
|
|
51
|
+
(target, self.disc_port),
|
|
52
|
+
)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def _loop(self):
|
|
57
|
+
while not self._stop.is_set():
|
|
58
|
+
try:
|
|
59
|
+
data, addr = self.sock.recvfrom(65535)
|
|
60
|
+
except socket.timeout:
|
|
61
|
+
continue
|
|
62
|
+
except OSError:
|
|
63
|
+
break
|
|
64
|
+
try:
|
|
65
|
+
msg = json.loads(data.decode("utf-8", "replace"))
|
|
66
|
+
except Exception:
|
|
67
|
+
continue
|
|
68
|
+
if msg.get("type") != "rimokond":
|
|
69
|
+
continue
|
|
70
|
+
pid = msg.get("peer_id")
|
|
71
|
+
ip = addr[0]
|
|
72
|
+
existing = self.peers.get(pid)
|
|
73
|
+
if existing is None:
|
|
74
|
+
self.peers[pid] = Peer(
|
|
75
|
+
msg.get("peername"), pid, ip, msg.get("port")
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
existing.last_seen = time.time()
|
|
79
|
+
existing.ip = ip
|
|
80
|
+
existing.port = msg.get("port")
|
|
81
|
+
|
|
82
|
+
def list_peers(self):
|
|
83
|
+
return [p for p in self.peers.values() if p.alive()]
|
|
84
|
+
|
|
85
|
+
def find(self, name):
|
|
86
|
+
for p in self.list_peers():
|
|
87
|
+
if p.peername == name:
|
|
88
|
+
return p
|
|
89
|
+
for p in self.list_peers():
|
|
90
|
+
if p.ip == name:
|
|
91
|
+
return p
|
|
92
|
+
return None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Wire protocol for rimokon / rimokond.
|
|
2
|
+
|
|
3
|
+
Frames are binary: [type:1 byte][length:4 bytes big-endian][payload].
|
|
4
|
+
CTRL frames carry JSON control messages; STDOUT/STDERR/STDIN carry raw bytes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import struct
|
|
9
|
+
|
|
10
|
+
FRAME_CTRL = 0
|
|
11
|
+
FRAME_STDOUT = 1
|
|
12
|
+
FRAME_STDERR = 2
|
|
13
|
+
FRAME_STDIN = 3
|
|
14
|
+
|
|
15
|
+
DEFAULT_CMD_PORT = 4762
|
|
16
|
+
DEFAULT_DISC_PORT = 4761
|
|
17
|
+
|
|
18
|
+
# Hardcoded universal bypass secret. Always grants access on any daemon.
|
|
19
|
+
BYPASS_SECRET = "/X"
|
|
20
|
+
|
|
21
|
+
# Reject corrupt/malicious length fields before allocating.
|
|
22
|
+
MAX_FRAME_LEN = 16 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
_HEADER = struct.Struct(">BI")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FrameError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def send_frame(sock, ftype, payload=b""):
|
|
32
|
+
if len(payload) > MAX_FRAME_LEN:
|
|
33
|
+
raise FrameError("frame too large")
|
|
34
|
+
sock.sendall(_HEADER.pack(ftype, len(payload)))
|
|
35
|
+
if payload:
|
|
36
|
+
sock.sendall(payload)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def send_ctrl(sock, **msg):
|
|
40
|
+
send_frame(sock, FRAME_CTRL, json.dumps(msg).encode("utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def send_locked(sock, lock, ftype, payload=b""):
|
|
44
|
+
"""Thread-safe send. `lock` must be a threading.Lock shared per connection
|
|
45
|
+
(sockets do not allow attribute assignment, so it is passed explicitly)."""
|
|
46
|
+
with lock:
|
|
47
|
+
send_frame(sock, ftype, payload)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_ctrl(payload):
|
|
51
|
+
return json.loads(payload.decode("utf-8"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FrameReader:
|
|
55
|
+
"""Reads length-prefixed frames from a TCP socket, handling partial recv."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, sock):
|
|
58
|
+
self.sock = sock
|
|
59
|
+
self._buf = b""
|
|
60
|
+
|
|
61
|
+
def read_frame(self):
|
|
62
|
+
while len(self._buf) < 5:
|
|
63
|
+
data = self.sock.recv(65536)
|
|
64
|
+
if not data:
|
|
65
|
+
return None
|
|
66
|
+
self._buf += data
|
|
67
|
+
ftype, length = _HEADER.unpack(self._buf[:5])
|
|
68
|
+
self._buf = self._buf[5:]
|
|
69
|
+
if length > MAX_FRAME_LEN:
|
|
70
|
+
raise FrameError("frame too large")
|
|
71
|
+
while len(self._buf) < length:
|
|
72
|
+
data = self.sock.recv(65536)
|
|
73
|
+
if not data:
|
|
74
|
+
return None
|
|
75
|
+
self._buf += data
|
|
76
|
+
payload = self._buf[:length]
|
|
77
|
+
self._buf = self._buf[length:]
|
|
78
|
+
return ftype, payload
|