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.
@@ -0,0 +1,5 @@
1
+ # Gitignore settings for ESPHome
2
+ # This is an example and may include too much for your use-case.
3
+ # You can modify this file to suit your needs.
4
+ /.esphome/
5
+ /secrets.yaml
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`.
@@ -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,6 @@
1
+ """rimokon entry point."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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