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.
- rimokond-0.1.0/.gitignore +5 -0
- rimokond-0.1.0/PKG-INFO +38 -0
- rimokond-0.1.0/README.md +25 -0
- rimokond-0.1.0/pyproject.toml +24 -0
- rimokond-0.1.0/src/rimokond/__init__.py +1 -0
- rimokond-0.1.0/src/rimokond/__main__.py +49 -0
- rimokond-0.1.0/src/rimokond/config.py +40 -0
- rimokond-0.1.0/src/rimokond/daemon.py +216 -0
- rimokond-0.1.0/src/rimokond/discovery.py +72 -0
- rimokond-0.1.0/src/rimokond/protocol.py +78 -0
- rimokond-0.1.0/src/rimokond/service.py +258 -0
- rimokond-0.1.0/src/rimokond/shell.py +85 -0
rimokond-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
rimokond-0.1.0/README.md
ADDED
|
@@ -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
|