gcli-control 0.2.0__tar.gz → 0.3.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.
- {gcli_control-0.2.0 → gcli_control-0.3.0}/PKG-INFO +2 -2
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/__init__.py +1 -1
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/client.py +165 -87
- gcli_control-0.3.0/gcli/crypto.py +97 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/host.py +11 -1
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/monitoring.py +2 -1
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/netutils.py +2 -1
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/protocol.py +13 -29
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/security.py +23 -0
- gcli_control-0.3.0/gcli/system.py +767 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli_control.egg-info/PKG-INFO +2 -2
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli_control.egg-info/SOURCES.txt +1 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/pyproject.toml +2 -2
- gcli_control-0.2.0/gcli/crypto.py +0 -51
- {gcli_control-0.2.0 → gcli_control-0.3.0}/README.md +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/__main__.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/aliases.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/clipboard.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/colors.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/fileops.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/npoint.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/output.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/processes.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/session.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli/utils.py +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli_control.egg-info/dependency_links.txt +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli_control.egg-info/entry_points.txt +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli_control.egg-info/requires.txt +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/gcli_control.egg-info/top_level.txt +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/setup.cfg +0 -0
- {gcli_control-0.2.0 → gcli_control-0.3.0}/tests/test_integration.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gcli-control
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Remote access tool via npoint.io - encrypted command relay
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Remote access tool via npoint.io - encrypted command relay with compression, caching, and advanced system commands
|
|
5
5
|
Author: gcli contributors
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/gcli-control/gcli
|
|
@@ -156,99 +156,168 @@ class SSHSession:
|
|
|
156
156
|
if not line:
|
|
157
157
|
continue
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
self._do_help()
|
|
165
|
-
elif line == ":info":
|
|
166
|
-
self._do_info()
|
|
167
|
-
elif line == ":ping":
|
|
168
|
-
self._do_ping()
|
|
169
|
-
elif line == ":history":
|
|
170
|
-
self._do_history()
|
|
171
|
-
elif line.startswith(":timeout"):
|
|
172
|
-
self._do_timeout(line)
|
|
173
|
-
elif line.startswith(":upload"):
|
|
174
|
-
self._do_upload(line)
|
|
175
|
-
elif line.startswith(":download"):
|
|
176
|
-
self._do_download(line)
|
|
177
|
-
# File operations
|
|
178
|
-
elif line == ":ls" or line.startswith(":ls "):
|
|
179
|
-
self._do_feature(line, "ls", {"type": "ls"})
|
|
180
|
-
elif line == ":tree" or line.startswith(":tree "):
|
|
181
|
-
self._do_feature_tree(line)
|
|
182
|
-
elif line.startswith(":mkdir "):
|
|
183
|
-
self._do_feature(line, "mkdir", {"type": "mkdir", "path": line.split(None, 1)[1]})
|
|
184
|
-
elif line.startswith(":rm "):
|
|
185
|
-
self._do_feature(line, "rm", {"type": "rm", "path": line.split(None, 1)[1]})
|
|
186
|
-
elif line.startswith(":find "):
|
|
187
|
-
self._do_feature(line, "find", {"type": "find", "path": ".", "pattern": line.split(None, 1)[1]})
|
|
188
|
-
elif line.startswith(":stat "):
|
|
189
|
-
self._do_feature(line, "stat", {"type": "stat", "path": line.split(None, 1)[1]})
|
|
190
|
-
# Process operations
|
|
191
|
-
elif line == ":ps" or line.startswith(":ps "):
|
|
192
|
-
limit = line.split()[1] if len(line.split()) > 1 else "15"
|
|
193
|
-
self._do_feature(line, "ps", {"type": "ps", "limit": int(limit)})
|
|
194
|
-
elif line.startswith(":kill "):
|
|
195
|
-
self._do_feature(line, "kill", {"type": "kill", "pid": int(line.split()[1])})
|
|
196
|
-
elif line == ":uptime":
|
|
197
|
-
self._do_feature(line, "uptime", {"type": "uptime"})
|
|
198
|
-
# Monitoring
|
|
199
|
-
elif line == ":cpu":
|
|
200
|
-
self._do_feature(line, "cpu", {"type": "cpu_usage"})
|
|
201
|
-
elif line == ":mem":
|
|
202
|
-
self._do_feature(line, "mem", {"type": "memory_usage"})
|
|
203
|
-
elif line == ":disk" or line.startswith(":disk "):
|
|
204
|
-
path = line.split()[1] if len(line.split()) > 1 else "/"
|
|
205
|
-
self._do_feature(line, "disk", {"type": "disk_usage", "path": path})
|
|
206
|
-
elif line == ":top" or line.startswith(":top "):
|
|
207
|
-
limit = line.split()[1] if len(line.split()) > 1 else "5"
|
|
208
|
-
self._do_feature(line, "top", {"type": "top_processes", "metric": "cpu", "limit": int(limit)})
|
|
209
|
-
# Clipboard
|
|
210
|
-
elif line == ":clipget":
|
|
211
|
-
self._do_feature(line, "clipget", {"type": "clip_get"})
|
|
212
|
-
elif line.startswith(":clipset "):
|
|
213
|
-
self._do_feature(line, "clipset", {"type": "clip_set", "content": line.split(None, 1)[1]})
|
|
214
|
-
elif line == ":clipclear":
|
|
215
|
-
self._do_feature(line, "clipclear", {"type": "clip_clear"})
|
|
216
|
-
# Network
|
|
217
|
-
elif line.startswith(":dns "):
|
|
218
|
-
self._do_feature(line, "dns", {"type": "dns_lookup", "hostname": line.split()[1]})
|
|
219
|
-
elif line.startswith(":portcheck "):
|
|
220
|
-
parts = line.split()
|
|
221
|
-
self._do_feature(line, "portcheck", {"type": "port_check", "host": parts[1], "port": int(parts[2])})
|
|
222
|
-
# Aliases
|
|
223
|
-
elif line.startswith(":alias "):
|
|
224
|
-
parts = line.split(None, 2)
|
|
225
|
-
if len(parts) >= 3:
|
|
226
|
-
self._do_feature(line, "alias", {"type": "alias", "name": parts[1], "command": parts[2]})
|
|
227
|
-
else:
|
|
228
|
-
print(warn("Usage: :alias <name> <command>"))
|
|
229
|
-
elif line == ":aliases":
|
|
230
|
-
self._do_feature(line, "aliases", {"type": "list_aliases"})
|
|
231
|
-
elif line.startswith(":script "):
|
|
232
|
-
cmds = line.split(None, 1)[1].split(";")
|
|
233
|
-
self._do_feature(line, "script", {"type": "run_script", "commands": [c.strip() for c in cmds]})
|
|
234
|
-
# Security
|
|
235
|
-
elif line == ":audit":
|
|
236
|
-
self._do_feature(line, "audit", {"type": "get_audit_log", "limit": 20})
|
|
237
|
-
elif line.startswith(":threat "):
|
|
238
|
-
self._do_feature(line, "threat", {"type": "threat_check", "command": line.split(None, 1)[1]})
|
|
239
|
-
elif line == ":lock":
|
|
240
|
-
self._do_feature(line, "lock", {"type": "lock_session", "reason": "manual"})
|
|
241
|
-
elif line == ":unlock":
|
|
242
|
-
self._do_feature(line, "unlock", {"type": "unlock_session"})
|
|
243
|
-
else:
|
|
244
|
-
# Remote exec
|
|
245
|
-
self._do_exec(line)
|
|
159
|
+
try:
|
|
160
|
+
self._dispatch_line(line)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
print(error(f"[gcli] Error: {e}"))
|
|
163
|
+
continue
|
|
246
164
|
except Exception as e:
|
|
247
165
|
logger.error("REPL error: %s", e)
|
|
248
166
|
finally:
|
|
249
167
|
self.connected = False
|
|
250
168
|
print(info("[gcli] Session closed."))
|
|
251
169
|
|
|
170
|
+
def _dispatch_line(self, line: str) -> None:
|
|
171
|
+
"""Dispatch a single REPL input line to the appropriate handler."""
|
|
172
|
+
# Built-in commands
|
|
173
|
+
if line == ":exit" or line == ":quit":
|
|
174
|
+
self._send_disconnect()
|
|
175
|
+
self.connected = False
|
|
176
|
+
return
|
|
177
|
+
elif line == ":help":
|
|
178
|
+
self._do_help()
|
|
179
|
+
elif line == ":info":
|
|
180
|
+
self._do_info()
|
|
181
|
+
elif line == ":ping":
|
|
182
|
+
self._do_ping()
|
|
183
|
+
elif line == ":history":
|
|
184
|
+
self._do_history()
|
|
185
|
+
elif line.startswith(":timeout"):
|
|
186
|
+
self._do_timeout(line)
|
|
187
|
+
elif line.startswith(":upload"):
|
|
188
|
+
self._do_upload(line)
|
|
189
|
+
elif line.startswith(":download"):
|
|
190
|
+
self._do_download(line)
|
|
191
|
+
# File operations
|
|
192
|
+
elif line == ":ls" or line.startswith(":ls "):
|
|
193
|
+
ls_path = line.split(None, 1)[1] if len(line.split()) > 1 else "."
|
|
194
|
+
self._do_feature(line, "ls", {"type": "ls", "path": ls_path})
|
|
195
|
+
elif line == ":tree" or line.startswith(":tree "):
|
|
196
|
+
self._do_feature_tree(line)
|
|
197
|
+
elif line.startswith(":mkdir "):
|
|
198
|
+
self._do_feature(line, "mkdir", {"type": "mkdir", "path": line.split(None, 1)[1]})
|
|
199
|
+
elif line.startswith(":rm "):
|
|
200
|
+
self._do_feature(line, "rm", {"type": "rm", "path": line.split(None, 1)[1]})
|
|
201
|
+
elif line.startswith(":find "):
|
|
202
|
+
self._do_feature(line, "find", {"type": "find", "path": ".", "pattern": line.split(None, 1)[1]})
|
|
203
|
+
elif line.startswith(":stat "):
|
|
204
|
+
self._do_feature(line, "stat", {"type": "stat", "path": line.split(None, 1)[1]})
|
|
205
|
+
# Process operations
|
|
206
|
+
elif line == ":ps" or line.startswith(":ps "):
|
|
207
|
+
try:
|
|
208
|
+
limit = int(line.split()[1]) if len(line.split()) > 1 else 15
|
|
209
|
+
except (ValueError, IndexError):
|
|
210
|
+
limit = 15
|
|
211
|
+
self._do_feature(line, "ps", {"type": "ps", "limit": limit})
|
|
212
|
+
elif line.startswith(":kill "):
|
|
213
|
+
try:
|
|
214
|
+
pid = int(line.split()[1])
|
|
215
|
+
except (ValueError, IndexError):
|
|
216
|
+
print(warn("Usage: :kill <pid>"))
|
|
217
|
+
return
|
|
218
|
+
self._do_feature(line, "kill", {"type": "kill", "pid": pid})
|
|
219
|
+
elif line == ":uptime":
|
|
220
|
+
self._do_feature(line, "uptime", {"type": "uptime"})
|
|
221
|
+
# Monitoring
|
|
222
|
+
elif line == ":cpu":
|
|
223
|
+
self._do_feature(line, "cpu", {"type": "cpu_usage"})
|
|
224
|
+
elif line == ":mem":
|
|
225
|
+
self._do_feature(line, "mem", {"type": "memory_usage"})
|
|
226
|
+
elif line == ":disk" or line.startswith(":disk "):
|
|
227
|
+
path = line.split()[1] if len(line.split()) > 1 else "/"
|
|
228
|
+
self._do_feature(line, "disk", {"type": "disk_usage", "path": path})
|
|
229
|
+
elif line == ":top" or line.startswith(":top "):
|
|
230
|
+
try:
|
|
231
|
+
limit = int(line.split()[1]) if len(line.split()) > 1 else 5
|
|
232
|
+
except (ValueError, IndexError):
|
|
233
|
+
limit = 5
|
|
234
|
+
self._do_feature(line, "top", {"type": "top_processes", "metric": "cpu", "limit": limit})
|
|
235
|
+
# Clipboard
|
|
236
|
+
elif line == ":clipget":
|
|
237
|
+
self._do_feature(line, "clipget", {"type": "clip_get"})
|
|
238
|
+
elif line.startswith(":clipset "):
|
|
239
|
+
self._do_feature(line, "clipset", {"type": "clip_set", "content": line.split(None, 1)[1]})
|
|
240
|
+
elif line == ":clipclear":
|
|
241
|
+
self._do_feature(line, "clipclear", {"type": "clip_clear"})
|
|
242
|
+
# Network
|
|
243
|
+
elif line.startswith(":dns "):
|
|
244
|
+
self._do_feature(line, "dns", {"type": "dns_lookup", "hostname": line.split()[1]})
|
|
245
|
+
elif line.startswith(":portcheck "):
|
|
246
|
+
parts = line.split()
|
|
247
|
+
if len(parts) < 3:
|
|
248
|
+
print(warn("Usage: :portcheck <host> <port>"))
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
port = int(parts[2])
|
|
252
|
+
except ValueError:
|
|
253
|
+
print(warn("Port must be a number"))
|
|
254
|
+
return
|
|
255
|
+
self._do_feature(line, "portcheck", {"type": "port_check", "host": parts[1], "port": port})
|
|
256
|
+
# Aliases
|
|
257
|
+
elif line.startswith(":alias "):
|
|
258
|
+
parts = line.split(None, 2)
|
|
259
|
+
if len(parts) >= 3:
|
|
260
|
+
self._do_feature(line, "alias", {"type": "alias", "name": parts[1], "command": parts[2]})
|
|
261
|
+
else:
|
|
262
|
+
print(warn("Usage: :alias <name> <command>"))
|
|
263
|
+
elif line == ":aliases":
|
|
264
|
+
self._do_feature(line, "aliases", {"type": "list_aliases"})
|
|
265
|
+
elif line.startswith(":script "):
|
|
266
|
+
cmds = line.split(None, 1)[1].split(";")
|
|
267
|
+
self._do_feature(line, "script", {"type": "run_script", "commands": [c.strip() for c in cmds]})
|
|
268
|
+
# Security
|
|
269
|
+
elif line == ":audit":
|
|
270
|
+
self._do_feature(line, "audit", {"type": "get_audit_log", "limit": 20})
|
|
271
|
+
elif line.startswith(":threat "):
|
|
272
|
+
self._do_feature(line, "threat", {"type": "threat_check", "command": line.split(None, 1)[1]})
|
|
273
|
+
elif line == ":lock":
|
|
274
|
+
self._do_feature(line, "lock", {"type": "lock_session", "reason": "manual"})
|
|
275
|
+
elif line == ":unlock":
|
|
276
|
+
self._do_feature(line, "unlock", {"type": "unlock_session"})
|
|
277
|
+
# System commands
|
|
278
|
+
elif line == ":netstat":
|
|
279
|
+
self._do_feature(line, "netstat", {"type": "netstat"})
|
|
280
|
+
elif line.startswith(":services"):
|
|
281
|
+
parts = line.split()
|
|
282
|
+
if len(parts) >= 3 and parts[1] in ("start", "stop"):
|
|
283
|
+
self._do_feature(line, "services", {"type": "services", "action": parts[1], "name": parts[2]})
|
|
284
|
+
elif len(parts) == 2 and parts[1] == "list":
|
|
285
|
+
self._do_feature(line, "services", {"type": "services", "action": "list"})
|
|
286
|
+
else:
|
|
287
|
+
self._do_feature(line, "services", {"type": "services", "action": "list"})
|
|
288
|
+
elif line == ":screenshot":
|
|
289
|
+
self._do_feature(line, "screenshot", {"type": "screenshot"})
|
|
290
|
+
elif line.startswith(":history "):
|
|
291
|
+
try:
|
|
292
|
+
limit = int(line.split()[1])
|
|
293
|
+
except (ValueError, IndexError):
|
|
294
|
+
limit = 50
|
|
295
|
+
self._do_feature(line, "history", {"type": "shell_history", "limit": limit})
|
|
296
|
+
elif line == ":history":
|
|
297
|
+
self._do_feature(line, "history", {"type": "shell_history", "limit": 50})
|
|
298
|
+
elif line.startswith(":envset "):
|
|
299
|
+
parts = line.split(None, 2)
|
|
300
|
+
if len(parts) >= 3:
|
|
301
|
+
self._do_feature(line, "envset", {"type": "envset", "key": parts[1], "value": parts[2]})
|
|
302
|
+
else:
|
|
303
|
+
print(warn("Usage: :envset <KEY> <value>"))
|
|
304
|
+
elif line.startswith(":grep "):
|
|
305
|
+
parts = line.split()
|
|
306
|
+
if len(parts) >= 2:
|
|
307
|
+
pattern = parts[1]
|
|
308
|
+
path = parts[2] if len(parts) >= 3 else "."
|
|
309
|
+
include = parts[3] if len(parts) >= 4 else "*"
|
|
310
|
+
self._do_feature(line, "grep", {"type": "grep", "pattern": pattern, "path": path, "include": include})
|
|
311
|
+
else:
|
|
312
|
+
print(warn("Usage: :grep <pattern> [path] [include]"))
|
|
313
|
+
elif line == ":partitions":
|
|
314
|
+
self._do_feature(line, "partitions", {"type": "disk_partitions"})
|
|
315
|
+
elif line == ":whoami":
|
|
316
|
+
self._do_feature(line, "whoami", {"type": "whoami"})
|
|
317
|
+
else:
|
|
318
|
+
# Remote exec
|
|
319
|
+
self._do_exec(line)
|
|
320
|
+
|
|
252
321
|
# -------------------------------------------------------------------
|
|
253
322
|
# Built-in command handlers
|
|
254
323
|
# -------------------------------------------------------------------
|
|
@@ -294,6 +363,15 @@ class SSHSession:
|
|
|
294
363
|
print(f" {Style.GREEN}:threat <cmd>{Style.RESET} Check threat level")
|
|
295
364
|
print(f" {Style.GREEN}:lock{Style.RESET} Lock session")
|
|
296
365
|
print(f" {Style.GREEN}:unlock{Style.RESET} Unlock session")
|
|
366
|
+
print(f"\n{Style.CYAN}{Style.BOLD}--- Advanced System ---{Style.RESET}")
|
|
367
|
+
print(f" {Style.GREEN}:netstat{Style.RESET} Active network connections")
|
|
368
|
+
print(f" {Style.GREEN}:services [list|start|stop] <n>{Style.RESET} Manage services")
|
|
369
|
+
print(f" {Style.GREEN}:screenshot{Style.RESET} Capture remote screen")
|
|
370
|
+
print(f" {Style.GREEN}:history [n]{Style.RESET} Remote shell history (n=limit)")
|
|
371
|
+
print(f" {Style.GREEN}:envset <K> <V>{Style.RESET} Set env variable on host")
|
|
372
|
+
print(f" {Style.GREEN}:grep <pattern> [path] [ext]{Style.RESET} Search files")
|
|
373
|
+
print(f" {Style.GREEN}:partitions{Style.RESET} Disk partitions")
|
|
374
|
+
print(f" {Style.GREEN}:whoami{Style.RESET} Remote user info")
|
|
297
375
|
print(f"\n {Style.GREEN}:exit{Style.RESET} Disconnect")
|
|
298
376
|
print(f"{Style.CYAN}{Style.BOLD}{'=' * 40}{Style.RESET}\n")
|
|
299
377
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AES-256-GCM encryption/decryption for gcli.
|
|
3
|
+
Password -> PBKDF2 -> 256-bit key -> AES-256-GCM.
|
|
4
|
+
Includes optional zlib compression before encryption.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import zlib
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
from typing import Dict
|
|
12
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
13
|
+
|
|
14
|
+
SALT = b"gcli-v1-salt-2024" # fixed salt (password provides secrecy)
|
|
15
|
+
ITERATIONS = 600_000 # OWASP 2023 recommendation for PBKDF2-SHA256
|
|
16
|
+
|
|
17
|
+
# ── Key cache ─────────────────────────────────────────────────────────
|
|
18
|
+
# PBKDF2 with 600K iterations takes ~150ms. Cache derived keys per password.
|
|
19
|
+
_key_cache: Dict[str, bytes] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def derive_key(password: str) -> bytes:
|
|
23
|
+
"""Derive a 256-bit key from a password using PBKDF2 (cached)."""
|
|
24
|
+
cached = _key_cache.get(password)
|
|
25
|
+
if cached is not None:
|
|
26
|
+
return cached
|
|
27
|
+
key = hashlib.pbkdf2_hmac(
|
|
28
|
+
"sha256",
|
|
29
|
+
password.encode("utf-8"),
|
|
30
|
+
SALT,
|
|
31
|
+
ITERATIONS,
|
|
32
|
+
dklen=32,
|
|
33
|
+
)
|
|
34
|
+
_key_cache[password] = key
|
|
35
|
+
return key
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def clear_key_cache() -> None:
|
|
39
|
+
"""Clear the derived-key cache (e.g. on password change)."""
|
|
40
|
+
_key_cache.clear()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Compression helpers ──────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _compress(data: bytes) -> bytes:
|
|
46
|
+
"""Compress with zlib (level 6 = balanced speed/ratio)."""
|
|
47
|
+
return zlib.compress(data, 6)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decompress(data: bytes) -> bytes:
|
|
51
|
+
"""Decompress zlib data."""
|
|
52
|
+
return zlib.decompress(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Encrypt / Decrypt ────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def encrypt(plaintext: str, password: str, compress: bool = True) -> str:
|
|
58
|
+
"""Encrypt plaintext string -> base64 string.
|
|
59
|
+
|
|
60
|
+
Format: flag(1) + nonce(12) + ciphertext + tag(16).
|
|
61
|
+
Flag byte: 0x00 = raw, 0x01 = zlib compressed before encryption.
|
|
62
|
+
"""
|
|
63
|
+
key = derive_key(password)
|
|
64
|
+
raw = plaintext.encode("utf-8")
|
|
65
|
+
flag = b"\x00"
|
|
66
|
+
if compress and len(raw) > 128:
|
|
67
|
+
compressed = _compress(raw)
|
|
68
|
+
if len(compressed) < len(raw):
|
|
69
|
+
raw = compressed
|
|
70
|
+
flag = b"\x01"
|
|
71
|
+
nonce = os.urandom(12)
|
|
72
|
+
aesgcm = AESGCM(key)
|
|
73
|
+
ct = aesgcm.encrypt(nonce, raw, None)
|
|
74
|
+
return base64.b64encode(flag + nonce + ct).decode("ascii")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def decrypt(blob: str, password: str) -> str:
|
|
78
|
+
"""Decrypt base64 string -> plaintext string."""
|
|
79
|
+
key = derive_key(password)
|
|
80
|
+
raw = base64.b64decode(blob)
|
|
81
|
+
flag = raw[0:1]
|
|
82
|
+
nonce, ct = raw[1:13], raw[13:]
|
|
83
|
+
aesgcm = AESGCM(key)
|
|
84
|
+
plaintext = aesgcm.decrypt(nonce, ct, None)
|
|
85
|
+
if flag == b"\x01":
|
|
86
|
+
plaintext = _decompress(plaintext)
|
|
87
|
+
return plaintext.decode("utf-8")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def encrypt_dict(data: dict, password: str, compress: bool = True) -> str:
|
|
91
|
+
"""Encrypt a dict -> base64 string (JSON-serialised then compressed+encrypted)."""
|
|
92
|
+
return encrypt(json.dumps(data, separators=(",", ":")), password, compress=compress)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def decrypt_dict(blob: str, password: str) -> dict:
|
|
96
|
+
"""Decrypt a base64 string -> dict."""
|
|
97
|
+
return json.loads(decrypt(blob, password))
|
|
@@ -27,6 +27,7 @@ _netutils = None
|
|
|
27
27
|
_aliases = None
|
|
28
28
|
_security = None
|
|
29
29
|
_monitoring = None
|
|
30
|
+
_system = None
|
|
30
31
|
|
|
31
32
|
logger = logging.getLogger("gcli.host")
|
|
32
33
|
|
|
@@ -43,11 +44,13 @@ _SECURITY_TYPES = {"rate_check", "audit_log", "get_audit_log", "clear_audit_log"
|
|
|
43
44
|
"rotate_key", "threat_check", "lock_session", "unlock_session"}
|
|
44
45
|
_MONITOR_TYPES = {"cpu_usage", "memory_usage", "disk_usage", "disk_list",
|
|
45
46
|
"top_processes", "io_stats", "temperature", "battery", "snapshot"}
|
|
47
|
+
_SYSTEM_TYPES = {"netstat", "services", "screenshot", "shell_history",
|
|
48
|
+
"envset", "grep", "disk_partitions", "whoami"}
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
def _get_module(name):
|
|
49
52
|
"""Lazy-import a feature module."""
|
|
50
|
-
global _fileops, _processes, _session, _clipboard, _netutils, _aliases, _security, _monitoring
|
|
53
|
+
global _fileops, _processes, _session, _clipboard, _netutils, _aliases, _security, _monitoring, _system
|
|
51
54
|
if name == "fileops":
|
|
52
55
|
if _fileops is None:
|
|
53
56
|
from . import fileops as _fileops_mod
|
|
@@ -83,6 +86,11 @@ def _get_module(name):
|
|
|
83
86
|
from . import monitoring as _mon_mod
|
|
84
87
|
_monitoring = _mon_mod
|
|
85
88
|
return _monitoring
|
|
89
|
+
elif name == "system":
|
|
90
|
+
if _system is None:
|
|
91
|
+
from . import system as _sys_mod
|
|
92
|
+
_system = _sys_mod
|
|
93
|
+
return _system
|
|
86
94
|
return None
|
|
87
95
|
|
|
88
96
|
|
|
@@ -194,6 +202,8 @@ class Daemon:
|
|
|
194
202
|
self._dispatch_module(cmd_id, "security", payload)
|
|
195
203
|
elif cmd_type in _MONITOR_TYPES:
|
|
196
204
|
self._dispatch_module(cmd_id, "monitoring", payload)
|
|
205
|
+
elif cmd_type in _SYSTEM_TYPES:
|
|
206
|
+
self._dispatch_module(cmd_id, "system", payload)
|
|
197
207
|
else:
|
|
198
208
|
self._respond(cmd_id, {"ok": False, "error": f"Unknown command type: {cmd_type}"})
|
|
199
209
|
|
|
@@ -788,7 +788,8 @@ def dispatch(payload: dict) -> dict:
|
|
|
788
788
|
elif cmd_type == "memory_usage":
|
|
789
789
|
return mon.memory_usage()
|
|
790
790
|
elif cmd_type == "disk_usage":
|
|
791
|
-
|
|
791
|
+
default_path = "C:\\" if sys.platform == "win32" else "/"
|
|
792
|
+
return mon.disk_usage(path=payload.get("path") or default_path)
|
|
792
793
|
elif cmd_type == "disk_list":
|
|
793
794
|
return mon.disk_list()
|
|
794
795
|
elif cmd_type == "top_processes":
|
|
@@ -374,7 +374,8 @@ def dispatch(payload: dict) -> dict:
|
|
|
374
374
|
max_hops=payload.get("max_hops", 15),
|
|
375
375
|
)
|
|
376
376
|
elif cmd_type == "speed_test":
|
|
377
|
-
|
|
377
|
+
url = payload.get("url") or "http://speedtest.tele2.net/1MB.zip"
|
|
378
|
+
return nu.speed_test(url=url)
|
|
378
379
|
elif cmd_type == "ports_scan":
|
|
379
380
|
host = payload.get("host", "")
|
|
380
381
|
ports = payload.get("ports", [])
|
|
@@ -118,12 +118,8 @@ def _safe_append(bin_id: str, field: str, new_entry: dict) -> None:
|
|
|
118
118
|
"""
|
|
119
119
|
Read-modify-write with merge. Retries up to 3 times on conflict.
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
2. Append new entry.
|
|
124
|
-
3. Re-read to pick up any concurrent writes that happened during our write.
|
|
125
|
-
4. Merge any new entries from the re-read that we missed.
|
|
126
|
-
5. Write back.
|
|
121
|
+
OPTIMIZATION: On the common-case success path, we skip the re-read
|
|
122
|
+
(saves 1 HTTP round-trip). Only re-read if we suspect a conflict.
|
|
127
123
|
"""
|
|
128
124
|
MAX_ATTEMPTS = 3
|
|
129
125
|
# Step 1: read
|
|
@@ -140,26 +136,7 @@ def _safe_append(bin_id: str, field: str, new_entry: dict) -> None:
|
|
|
140
136
|
for attempt in range(MAX_ATTEMPTS):
|
|
141
137
|
try:
|
|
142
138
|
write_doc(bin_id, doc)
|
|
143
|
-
#
|
|
144
|
-
doc2 = read_doc(bin_id)
|
|
145
|
-
doc2_ids = {e["id"] for e in doc2.get(field, [])}
|
|
146
|
-
missing = [e for e in doc.get(field, []) if e["id"] not in doc2_ids]
|
|
147
|
-
if missing:
|
|
148
|
-
# Our write was overwritten by a concurrent write - re-merge
|
|
149
|
-
for entry in missing:
|
|
150
|
-
doc2.setdefault(field, []).append(entry)
|
|
151
|
-
# Also merge their new entries we didn't have
|
|
152
|
-
existing = {e["id"] for e in doc2.get(field, [])}
|
|
153
|
-
for entry in doc.get(field, []):
|
|
154
|
-
if entry["id"] not in existing:
|
|
155
|
-
doc2.setdefault(field, []).append(entry)
|
|
156
|
-
# Carry over other fields
|
|
157
|
-
for key in ("session", "host_alive"):
|
|
158
|
-
if key in doc:
|
|
159
|
-
doc2[key] = doc[key]
|
|
160
|
-
doc2 = trim(doc)
|
|
161
|
-
write_doc(bin_id, doc2)
|
|
162
|
-
return
|
|
139
|
+
return # success — skip the re-read on the common path
|
|
163
140
|
except RuntimeError as e:
|
|
164
141
|
logger.debug("Write failed on attempt %d: %s", attempt + 1, e)
|
|
165
142
|
if attempt < MAX_ATTEMPTS - 1:
|
|
@@ -194,19 +171,26 @@ def claim_results(doc: dict, password: str, target_cmd_id: str, seen: Set[str])
|
|
|
194
171
|
"""
|
|
195
172
|
Look for a result matching target_cmd_id that we haven't seen yet.
|
|
196
173
|
Returns the decrypted payload or None.
|
|
174
|
+
|
|
175
|
+
OPTIMIZATION: We store cmd_id in the entry metadata (unencrypted),
|
|
176
|
+
so we can check it before expensive decryption (~150ms saved per non-match).
|
|
197
177
|
"""
|
|
198
178
|
for entry in doc.get("results", []):
|
|
199
179
|
res_id = entry.get("id", "")
|
|
200
180
|
if res_id in seen:
|
|
201
181
|
continue
|
|
182
|
+
# FAST PATH: check unencrypted cmd_id in entry metadata first
|
|
183
|
+
if entry.get("cmd_id") != target_cmd_id:
|
|
184
|
+
seen.add(res_id) # mark as seen so we skip it next time
|
|
185
|
+
continue
|
|
186
|
+
# Slow path: only decrypt entries that match our target
|
|
202
187
|
try:
|
|
203
188
|
payload = decrypt_dict(entry["payload"], password)
|
|
189
|
+
seen.add(res_id)
|
|
190
|
+
return payload
|
|
204
191
|
except Exception:
|
|
205
192
|
seen.add(res_id)
|
|
206
193
|
continue
|
|
207
|
-
if payload.get("cmd_id") == target_cmd_id:
|
|
208
|
-
seen.add(res_id)
|
|
209
|
-
return payload
|
|
210
194
|
return None
|
|
211
195
|
|
|
212
196
|
|
|
@@ -243,3 +243,26 @@ if __name__ == "__main__":
|
|
|
243
243
|
sm = SecurityManager()
|
|
244
244
|
sm.test_security()
|
|
245
245
|
print("All security tests passed.")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Module-level dispatch (called by host._dispatch_module)
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
_manager: Optional["SecurityManager"] = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _get_manager() -> "SecurityManager":
|
|
256
|
+
global _manager
|
|
257
|
+
if _manager is None:
|
|
258
|
+
_manager = SecurityManager()
|
|
259
|
+
return _manager
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def dispatch(payload: dict) -> dict:
|
|
263
|
+
"""Route a security command payload to SecurityManager.dispatch().
|
|
264
|
+
|
|
265
|
+
>>> dispatch({"type": "threat_check", "command": "ls"})["ok"]
|
|
266
|
+
True
|
|
267
|
+
"""
|
|
268
|
+
return _get_manager().dispatch(payload)
|