gcli-control 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- gcli/__main__.py +104 -0
- gcli/client.py +362 -0
- gcli/colors.py +85 -0
- gcli/crypto.py +51 -0
- gcli/host.py +215 -0
- gcli/npoint.py +60 -0
- gcli/protocol.py +246 -0
- gcli/utils.py +240 -0
- gcli_control-0.1.0.dist-info/METADATA +143 -0
- gcli_control-0.1.0.dist-info/RECORD +14 -0
- gcli_control-0.1.0.dist-info/WHEEL +5 -0
- gcli_control-0.1.0.dist-info/entry_points.txt +2 -0
- gcli_control-0.1.0.dist-info/top_level.txt +1 -0
gcli/__init__.py
ADDED
gcli/__main__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gcli — remote access tool via npoint.io.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
gcli host [--password P] [--bin ID] [--poll-interval N]
|
|
6
|
+
gcli ssh [--password P] [--bin ID]
|
|
7
|
+
gcli stop [--bin ID]
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
import getpass
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from .host import start_daemon, stop_daemon, DEFAULT_BIN_ID, DEFAULT_POLL
|
|
15
|
+
from .client import SSHSession
|
|
16
|
+
from .colors import banner, info, error
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("gcli")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cmd_host(args):
|
|
22
|
+
"""Start the gcli host daemon."""
|
|
23
|
+
password = args.password
|
|
24
|
+
if not password:
|
|
25
|
+
password = getpass.getpass("Password: ")
|
|
26
|
+
|
|
27
|
+
print(info(f"[gcli] Starting host daemon (bin={args.bin})..."))
|
|
28
|
+
start_daemon(
|
|
29
|
+
password=password,
|
|
30
|
+
bin_id=args.bin,
|
|
31
|
+
poll_interval=args.poll_interval,
|
|
32
|
+
foreground=args.foreground,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_ssh(args):
|
|
37
|
+
"""Connect to a remote gcli host."""
|
|
38
|
+
password = args.password
|
|
39
|
+
if not password:
|
|
40
|
+
password = getpass.getpass("Password: ")
|
|
41
|
+
|
|
42
|
+
print(banner())
|
|
43
|
+
session = SSHSession(
|
|
44
|
+
password=password,
|
|
45
|
+
bin_id=args.bin,
|
|
46
|
+
)
|
|
47
|
+
session.repl()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_stop(args):
|
|
51
|
+
"""Stop a running gcli daemon."""
|
|
52
|
+
stop_daemon(bin_id=args.bin)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="gcli",
|
|
58
|
+
description="Remote access tool via npoint.io — encrypted command relay",
|
|
59
|
+
)
|
|
60
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
61
|
+
|
|
62
|
+
# ---- gcli host ----
|
|
63
|
+
host_parser = subparsers.add_parser("host", help="Start the background host daemon")
|
|
64
|
+
host_parser.add_argument("-p", "--password", default=None, help="Encryption password")
|
|
65
|
+
host_parser.add_argument("-b", "--bin", default=DEFAULT_BIN_ID, help="npoint.io bin ID")
|
|
66
|
+
host_parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL,
|
|
67
|
+
help="Poll interval in seconds (default: 2.0)")
|
|
68
|
+
host_parser.add_argument("--foreground", action="store_true",
|
|
69
|
+
help="Run in foreground (don't detach)")
|
|
70
|
+
|
|
71
|
+
# ---- gcli ssh ----
|
|
72
|
+
ssh_parser = subparsers.add_parser("ssh", help="Connect to a remote gcli host")
|
|
73
|
+
ssh_parser.add_argument("-p", "--password", default=None, help="Encryption password")
|
|
74
|
+
ssh_parser.add_argument("-b", "--bin", default=DEFAULT_BIN_ID, help="npoint.io bin ID")
|
|
75
|
+
|
|
76
|
+
# ---- gcli stop ----
|
|
77
|
+
stop_parser = subparsers.add_parser("stop", help="Stop the host daemon")
|
|
78
|
+
stop_parser.add_argument("-b", "--bin", default=DEFAULT_BIN_ID, help="npoint.io bin ID")
|
|
79
|
+
|
|
80
|
+
args = parser.parse_args()
|
|
81
|
+
|
|
82
|
+
if not args.command:
|
|
83
|
+
print(banner())
|
|
84
|
+
parser.print_help()
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if args.command == "host":
|
|
89
|
+
print(banner())
|
|
90
|
+
cmd_host(args)
|
|
91
|
+
elif args.command == "ssh":
|
|
92
|
+
cmd_ssh(args)
|
|
93
|
+
elif args.command == "stop":
|
|
94
|
+
cmd_stop(args)
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
print(f"\n{error('[gcli] Interrupted.')}")
|
|
97
|
+
sys.exit(130)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error("Fatal error: %s", e)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
gcli/client.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gcli ssh — client that connects to a remote gcli host via npoint.
|
|
3
|
+
Sends encrypted commands, polls for results, provides interactive REPL.
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional, List, Dict
|
|
10
|
+
|
|
11
|
+
# readline: available on Linux/macOS, needs pyreadline on Windows
|
|
12
|
+
try:
|
|
13
|
+
import readline # noqa: F401
|
|
14
|
+
except ImportError:
|
|
15
|
+
try:
|
|
16
|
+
import pyreadline as readline # noqa: F401
|
|
17
|
+
except ImportError:
|
|
18
|
+
readline = None # type: ignore
|
|
19
|
+
|
|
20
|
+
from .protocol import (
|
|
21
|
+
read_doc, append_command, claim_results, claim_all_results,
|
|
22
|
+
is_host_alive, init_doc,
|
|
23
|
+
)
|
|
24
|
+
from .colors import Style, prompt, success, error, warn, info, banner
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("gcli.client")
|
|
27
|
+
|
|
28
|
+
DEFAULT_BIN_ID = "599ac3c39189fcc26e5a"
|
|
29
|
+
DEFAULT_POLL = 1.0 # seconds
|
|
30
|
+
DEFAULT_TIMEOUT = 30.0 # seconds for exec commands
|
|
31
|
+
|
|
32
|
+
BUILTIN_COMMANDS = [
|
|
33
|
+
":help",
|
|
34
|
+
":info",
|
|
35
|
+
":ping",
|
|
36
|
+
":upload <local_path> <remote_path>",
|
|
37
|
+
":download <remote_path> <local_path>",
|
|
38
|
+
":timeout <seconds>",
|
|
39
|
+
":history",
|
|
40
|
+
":exit",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _setup_completion():
|
|
45
|
+
"""Set up readline tab-completion for built-in commands."""
|
|
46
|
+
if readline is None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
def completer(text, state):
|
|
50
|
+
if text.startswith(":"):
|
|
51
|
+
matches = [c for c in BUILTIN_COMMANDS if c.startswith(text)]
|
|
52
|
+
else:
|
|
53
|
+
matches = []
|
|
54
|
+
return matches[state] if state < len(matches) else None
|
|
55
|
+
|
|
56
|
+
readline.set_completer(completer)
|
|
57
|
+
readline.set_completer_delims(" \t")
|
|
58
|
+
readline.parse_and_bind("tab: complete")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SSHSession:
|
|
62
|
+
"""Interactive session to a remote gcli host."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, password: str, bin_id: str = DEFAULT_BIN_ID,
|
|
65
|
+
poll_interval: float = DEFAULT_POLL):
|
|
66
|
+
self.password = password
|
|
67
|
+
self.bin_id = bin_id
|
|
68
|
+
self.poll_interval = poll_interval
|
|
69
|
+
self.seen_results: set = set()
|
|
70
|
+
self.connected = False
|
|
71
|
+
self.session_id = str(uuid.uuid4())
|
|
72
|
+
self.exec_timeout: float = DEFAULT_TIMEOUT
|
|
73
|
+
self.history: List[Dict] = [] # recent command/result pairs
|
|
74
|
+
|
|
75
|
+
def connect(self, timeout: float = 10.0) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Try to connect to the host by sending a ping and waiting for a pong.
|
|
78
|
+
Returns True if host responded.
|
|
79
|
+
"""
|
|
80
|
+
print(info(f"[gcli] Connecting to host via npoint (bin={self.bin_id})..."))
|
|
81
|
+
print(info("[gcli] Sending ping..."))
|
|
82
|
+
|
|
83
|
+
cmd_id = self._send("ping", {"type": "ping"})
|
|
84
|
+
|
|
85
|
+
deadline = time.time() + timeout
|
|
86
|
+
while time.time() < deadline:
|
|
87
|
+
try:
|
|
88
|
+
doc = read_doc(self.bin_id)
|
|
89
|
+
if not is_host_alive(doc):
|
|
90
|
+
time.sleep(self.poll_interval)
|
|
91
|
+
continue
|
|
92
|
+
result = claim_results(doc, self.password, cmd_id, self.seen_results)
|
|
93
|
+
if result and result.get("pong"):
|
|
94
|
+
print(success("[gcli] Host responded! Connected."))
|
|
95
|
+
self.connected = True
|
|
96
|
+
return True
|
|
97
|
+
except RuntimeError as e:
|
|
98
|
+
logger.debug("Read error during connect: %s", e)
|
|
99
|
+
time.sleep(self.poll_interval)
|
|
100
|
+
|
|
101
|
+
print(error("[gcli] Connection timed out. Is the host running?"))
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def repl(self) -> None:
|
|
105
|
+
"""Interactive command loop."""
|
|
106
|
+
_setup_completion()
|
|
107
|
+
|
|
108
|
+
if not self.connected:
|
|
109
|
+
if not self.connect():
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
print(f"\n{info('[gcli ssh] Type commands to execute on the remote host.')}")
|
|
113
|
+
print(info("[gcli] Type :help for available commands"))
|
|
114
|
+
print()
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
while self.connected:
|
|
118
|
+
try:
|
|
119
|
+
line = input(prompt("gcli")).strip()
|
|
120
|
+
except (EOFError, KeyboardInterrupt):
|
|
121
|
+
print(f"\n{warn('[gcli] Disconnecting...')}")
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
if not line:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Built-in commands
|
|
128
|
+
if line == ":exit" or line == ":quit":
|
|
129
|
+
self._send_disconnect()
|
|
130
|
+
break
|
|
131
|
+
elif line == ":help":
|
|
132
|
+
self._do_help()
|
|
133
|
+
elif line == ":info":
|
|
134
|
+
self._do_info()
|
|
135
|
+
elif line == ":ping":
|
|
136
|
+
self._do_ping()
|
|
137
|
+
elif line == ":history":
|
|
138
|
+
self._do_history()
|
|
139
|
+
elif line.startswith(":timeout"):
|
|
140
|
+
self._do_timeout(line)
|
|
141
|
+
elif line.startswith(":upload"):
|
|
142
|
+
self._do_upload(line)
|
|
143
|
+
elif line.startswith(":download"):
|
|
144
|
+
self._do_download(line)
|
|
145
|
+
else:
|
|
146
|
+
# Remote exec
|
|
147
|
+
self._do_exec(line)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error("REPL error: %s", e)
|
|
150
|
+
finally:
|
|
151
|
+
self.connected = False
|
|
152
|
+
print(info("[gcli] Session closed."))
|
|
153
|
+
|
|
154
|
+
# -------------------------------------------------------------------
|
|
155
|
+
# Built-in command handlers
|
|
156
|
+
# -------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _do_help(self) -> None:
|
|
159
|
+
"""Show available commands."""
|
|
160
|
+
print(f"\n{Style.CYAN}{Style.BOLD}--- Available Commands ---{Style.RESET}")
|
|
161
|
+
print(f" {Style.GREEN}Any text{Style.RESET} Execute shell command on remote host")
|
|
162
|
+
print(f" {Style.GREEN}:help{Style.RESET} Show this help message")
|
|
163
|
+
print(f" {Style.GREEN}:info{Style.RESET} Show remote system info")
|
|
164
|
+
print(f" {Style.GREEN}:ping{Style.RESET} Ping remote host (show latency)")
|
|
165
|
+
print(f" {Style.GREEN}:upload <local> <remote>{Style.RESET} Upload a file to remote host")
|
|
166
|
+
print(f" {Style.GREEN}:download <remote> <local>{Style.RESET} Download a file from remote host")
|
|
167
|
+
print(f" {Style.GREEN}:timeout <seconds>{Style.RESET} Set exec command timeout (current: {self.exec_timeout}s)")
|
|
168
|
+
print(f" {Style.GREEN}:history{Style.RESET} Show recent command history")
|
|
169
|
+
print(f" {Style.GREEN}:exit{Style.RESET} Disconnect and close session")
|
|
170
|
+
print(f"{Style.CYAN}{Style.BOLD}--------------------------{Style.RESET}\n")
|
|
171
|
+
|
|
172
|
+
def _do_timeout(self, line: str) -> None:
|
|
173
|
+
"""Set or display the exec timeout."""
|
|
174
|
+
parts = line.split()
|
|
175
|
+
if len(parts) == 1:
|
|
176
|
+
print(info(f"[gcli] Current timeout: {self.exec_timeout}s"))
|
|
177
|
+
return
|
|
178
|
+
try:
|
|
179
|
+
new_timeout = float(parts[1])
|
|
180
|
+
if new_timeout < 1:
|
|
181
|
+
print(error("[gcli] Timeout must be at least 1 second"))
|
|
182
|
+
return
|
|
183
|
+
self.exec_timeout = new_timeout
|
|
184
|
+
print(success(f"[gcli] Timeout set to {self.exec_timeout}s"))
|
|
185
|
+
except ValueError:
|
|
186
|
+
print(error("[gcli] Invalid timeout value. Usage: :timeout <seconds>"))
|
|
187
|
+
|
|
188
|
+
def _do_history(self) -> None:
|
|
189
|
+
"""Show recent command history."""
|
|
190
|
+
if not self.history:
|
|
191
|
+
print(info("[gcli] No command history yet."))
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
print(f"\n{Style.CYAN}{Style.BOLD}--- Command History ---{Style.RESET}")
|
|
195
|
+
for i, entry in enumerate(self.history[-20:], 1):
|
|
196
|
+
cmd = entry.get("cmd", "")
|
|
197
|
+
ok = entry.get("ok", False)
|
|
198
|
+
ts = entry.get("ts", "")
|
|
199
|
+
status_icon = f"{Style.GREEN}OK{Style.RESET}" if ok else f"{Style.RED}FAIL{Style.RESET}"
|
|
200
|
+
print(f" {Style.DIM}{i:3d}{Style.RESET} [{status_icon}] {cmd}")
|
|
201
|
+
print(f"{Style.CYAN}{Style.BOLD}-----------------------{Style.RESET}\n")
|
|
202
|
+
|
|
203
|
+
# -------------------------------------------------------------------
|
|
204
|
+
# Command handlers
|
|
205
|
+
# -------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _do_exec(self, cmd: str) -> None:
|
|
208
|
+
"""Send a command for remote execution and display the result."""
|
|
209
|
+
t0 = time.time()
|
|
210
|
+
cmd_id = self._send("exec", {"type": "exec", "cmd": cmd, "timeout": int(self.exec_timeout)})
|
|
211
|
+
result = self._poll_result(cmd_id, timeout=self.exec_timeout + 5)
|
|
212
|
+
elapsed = (time.time() - t0) * 1000
|
|
213
|
+
|
|
214
|
+
if result is None:
|
|
215
|
+
print(error("[gcli] No response from host (timed out)"))
|
|
216
|
+
self.history.append({"cmd": cmd, "ok": False, "ts": time.strftime("%H:%M:%S")})
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
ok = result.get("ok", False)
|
|
220
|
+
self.history.append({"cmd": cmd, "ok": ok, "ts": time.strftime("%H:%M:%S")})
|
|
221
|
+
|
|
222
|
+
if ok:
|
|
223
|
+
stdout = result.get("stdout", "")
|
|
224
|
+
stderr = result.get("stderr", "")
|
|
225
|
+
if stdout:
|
|
226
|
+
print(stdout, end="")
|
|
227
|
+
if stderr:
|
|
228
|
+
print(stderr, end="", file=sys.stderr)
|
|
229
|
+
else:
|
|
230
|
+
err = result.get("error") or result.get("stderr", "unknown error")
|
|
231
|
+
print(error(f"[gcli] Error: {err}"))
|
|
232
|
+
|
|
233
|
+
def _do_info(self) -> None:
|
|
234
|
+
"""Request remote system information."""
|
|
235
|
+
cmd_id = self._send("info", {"type": "info"})
|
|
236
|
+
result = self._poll_result(cmd_id)
|
|
237
|
+
if result is None:
|
|
238
|
+
print(error("[gcli] No response from host (timed out)"))
|
|
239
|
+
return
|
|
240
|
+
if result.get("ok"):
|
|
241
|
+
print(f"\n{Style.CYAN}{Style.BOLD}--- Remote System Info ---{Style.RESET}")
|
|
242
|
+
for k, v in result.items():
|
|
243
|
+
if k in ("ok", "cmd_id"):
|
|
244
|
+
continue
|
|
245
|
+
print(f" {Style.GREEN}{k:15s}{Style.RESET}: {v}")
|
|
246
|
+
print(f"{Style.CYAN}{Style.BOLD}--------------------------{Style.RESET}\n")
|
|
247
|
+
else:
|
|
248
|
+
print(error(f"[gcli] Info error: {result.get('error', 'unknown')}"))
|
|
249
|
+
|
|
250
|
+
def _do_ping(self) -> None:
|
|
251
|
+
"""Send a ping and show latency."""
|
|
252
|
+
t0 = time.time()
|
|
253
|
+
cmd_id = self._send("ping", {"type": "ping"})
|
|
254
|
+
result = self._poll_result(cmd_id)
|
|
255
|
+
if result and result.get("pong"):
|
|
256
|
+
latency = (time.time() - t0) * 1000
|
|
257
|
+
print(success(f"[gcli] Pong! Round-trip: {latency:.0f}ms"))
|
|
258
|
+
else:
|
|
259
|
+
print(error("[gcli] No pong received"))
|
|
260
|
+
|
|
261
|
+
def _do_upload(self, line: str) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Upload a local file to the remote host.
|
|
264
|
+
Usage: :upload <local_path> <remote_path>
|
|
265
|
+
"""
|
|
266
|
+
import base64
|
|
267
|
+
from pathlib import Path
|
|
268
|
+
|
|
269
|
+
parts = line.split(maxsplit=2)
|
|
270
|
+
if len(parts) < 3:
|
|
271
|
+
print(warn("Usage: :upload <local_path> <remote_path>"))
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
local_path = parts[1]
|
|
275
|
+
remote_path = parts[2]
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
raw = Path(local_path).read_bytes()
|
|
279
|
+
data_b64 = base64.b64encode(raw).decode("ascii")
|
|
280
|
+
except FileNotFoundError:
|
|
281
|
+
print(error(f"[gcli] Local file not found: {local_path}"))
|
|
282
|
+
return
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(error(f"[gcli] Read error: {e}"))
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
print(info(f"[gcli] Uploading {local_path} -> {remote_path} ({len(raw)} bytes)..."))
|
|
288
|
+
cmd_id = self._send("upload", {
|
|
289
|
+
"type": "upload",
|
|
290
|
+
"path": remote_path,
|
|
291
|
+
"data": data_b64,
|
|
292
|
+
})
|
|
293
|
+
result = self._poll_result(cmd_id)
|
|
294
|
+
if result is None:
|
|
295
|
+
print(error("[gcli] Upload timed out"))
|
|
296
|
+
elif result.get("ok"):
|
|
297
|
+
print(success(f"[gcli] Upload complete: {result.get('bytes_written', '?')} bytes written"))
|
|
298
|
+
else:
|
|
299
|
+
print(error(f"[gcli] Upload failed: {result.get('error', 'unknown')}"))
|
|
300
|
+
|
|
301
|
+
def _do_download(self, line: str) -> None:
|
|
302
|
+
"""
|
|
303
|
+
Download a file from the remote host.
|
|
304
|
+
Usage: :download <remote_path> <local_path>
|
|
305
|
+
"""
|
|
306
|
+
import base64
|
|
307
|
+
from pathlib import Path
|
|
308
|
+
|
|
309
|
+
parts = line.split(maxsplit=2)
|
|
310
|
+
if len(parts) < 3:
|
|
311
|
+
print(warn("Usage: :download <remote_path> <local_path>"))
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
remote_path = parts[1]
|
|
315
|
+
local_path = parts[2]
|
|
316
|
+
|
|
317
|
+
print(info(f"[gcli] Downloading {remote_path} -> {local_path}..."))
|
|
318
|
+
cmd_id = self._send("download", {
|
|
319
|
+
"type": "download",
|
|
320
|
+
"path": remote_path,
|
|
321
|
+
})
|
|
322
|
+
result = self._poll_result(cmd_id)
|
|
323
|
+
if result is None:
|
|
324
|
+
print(error("[gcli] Download timed out"))
|
|
325
|
+
elif result.get("ok"):
|
|
326
|
+
data_b64 = result.get("data", "")
|
|
327
|
+
raw = base64.b64decode(data_b64)
|
|
328
|
+
Path(local_path).write_bytes(raw)
|
|
329
|
+
print(success(f"[gcli] Download complete: {len(raw)} bytes written to {local_path}"))
|
|
330
|
+
else:
|
|
331
|
+
print(error(f"[gcli] Download failed: {result.get('error', 'unknown')}"))
|
|
332
|
+
|
|
333
|
+
def _send_disconnect(self) -> None:
|
|
334
|
+
"""Send a disconnect command to the host."""
|
|
335
|
+
cmd_id = self._send("disconnect", {"type": "disconnect"})
|
|
336
|
+
self._poll_result(cmd_id, timeout=3.0)
|
|
337
|
+
print(warn("[gcli] Disconnected."))
|
|
338
|
+
|
|
339
|
+
# -------------------------------------------------------------------
|
|
340
|
+
# Low-level send / poll
|
|
341
|
+
# -------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def _send(self, cmd_type: str, payload: dict) -> str:
|
|
344
|
+
"""Encrypt and send a command. Returns the command id."""
|
|
345
|
+
payload["type"] = cmd_type
|
|
346
|
+
payload["id"] = payload.get("id") or str(uuid.uuid4())
|
|
347
|
+
cmd_id = append_command(self.bin_id, self.password, payload)
|
|
348
|
+
return cmd_id
|
|
349
|
+
|
|
350
|
+
def _poll_result(self, cmd_id: str, timeout: float = 10.0) -> Optional[dict]:
|
|
351
|
+
"""Poll npoint for a result matching cmd_id. Returns decrypted payload or None."""
|
|
352
|
+
deadline = time.time() + timeout
|
|
353
|
+
while time.time() < deadline:
|
|
354
|
+
try:
|
|
355
|
+
doc = read_doc(self.bin_id)
|
|
356
|
+
result = claim_results(doc, self.password, cmd_id, self.seen_results)
|
|
357
|
+
if result is not None:
|
|
358
|
+
return result
|
|
359
|
+
except RuntimeError as e:
|
|
360
|
+
logger.debug("Read error during poll: %s", e)
|
|
361
|
+
time.sleep(self.poll_interval)
|
|
362
|
+
return None
|
gcli/colors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal colour helpers for gcli.
|
|
3
|
+
Works on both Windows (via ctypes/win32) and Linux/macOS (ANSI escapes).
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _setup_windows_console():
|
|
10
|
+
"""Enable ANSI escape sequences on Windows 10+."""
|
|
11
|
+
if sys.platform == "win32":
|
|
12
|
+
try:
|
|
13
|
+
import ctypes
|
|
14
|
+
kernel32 = ctypes.windll.kernel32
|
|
15
|
+
# STD_OUTPUT_HANDLE = -11
|
|
16
|
+
handle = kernel32.GetStdHandle(-11)
|
|
17
|
+
mode = ctypes.c_ulong()
|
|
18
|
+
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
|
19
|
+
# ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
20
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Initialise on import
|
|
26
|
+
_setup_windows_console()
|
|
27
|
+
|
|
28
|
+
# Check if we should use colours
|
|
29
|
+
_NO_COLOR = os.environ.get("NO_COLOR") or not sys.stdout.isatty()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Style:
|
|
33
|
+
RESET = "" if _NO_COLOR else "\033[0m"
|
|
34
|
+
BOLD = "" if _NO_COLOR else "\033[1m"
|
|
35
|
+
DIM = "" if _NO_COLOR else "\033[2m"
|
|
36
|
+
|
|
37
|
+
RED = "" if _NO_COLOR else "\033[91m"
|
|
38
|
+
GREEN = "" if _NO_COLOR else "\033[92m"
|
|
39
|
+
YELLOW = "" if _NO_COLOR else "\033[93m"
|
|
40
|
+
BLUE = "" if _NO_COLOR else "\033[94m"
|
|
41
|
+
MAGENTA = "" if _NO_COLOR else "\033[95m"
|
|
42
|
+
CYAN = "" if _NO_COLOR else "\033[96m"
|
|
43
|
+
WHITE = "" if _NO_COLOR else "\033[97m"
|
|
44
|
+
|
|
45
|
+
# Background
|
|
46
|
+
BG_RED = "" if _NO_COLOR else "\033[41m"
|
|
47
|
+
BG_GREEN = "" if _NO_COLOR else "\033[42m"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def coloured(text: str, style: str) -> str:
|
|
51
|
+
"""Wrap text with colour style."""
|
|
52
|
+
return f"{style}{text}{Style.RESET}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def prompt(text: str = "gcli") -> str:
|
|
56
|
+
"""Return a coloured prompt string."""
|
|
57
|
+
return f"{Style.CYAN}{Style.BOLD}{text}>{Style.RESET} "
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def success(text: str) -> str:
|
|
61
|
+
return f"{Style.GREEN}{text}{Style.RESET}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def error(text: str) -> str:
|
|
65
|
+
return f"{Style.RED}{text}{Style.RESET}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def warn(text: str) -> str:
|
|
69
|
+
return f"{Style.YELLOW}{text}{Style.RESET}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def info(text: str) -> str:
|
|
73
|
+
return f"{Style.CYAN}{text}{Style.RESET}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def banner() -> str:
|
|
77
|
+
"""Return the gcli startup banner."""
|
|
78
|
+
return f"""
|
|
79
|
+
{Style.CYAN}{Style.BOLD} _____ _____ ____ __ __ ___ _ _ _ _
|
|
80
|
+
/ ____| __/ ___|| \\/ |_ _| \\ | | / \\ | |
|
|
81
|
+
| | __| | | | __ | |\\/| || || \\| | / _ \\ | |
|
|
82
|
+
| |_| | |_| |___ || | | || || |\\ |/ ___ \\| |___
|
|
83
|
+
\\____|____/\\____/|_| |_|___|_| \\_/_/ \\_\\_____|
|
|
84
|
+
{Style.RESET}{Style.DIM} Remote access via npoint.io | Encrypted AES-256-GCM{Style.RESET}
|
|
85
|
+
"""
|
gcli/crypto.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AES-256-GCM encryption/decryption for gcli.
|
|
3
|
+
Password -> PBKDF2 -> 256-bit key -> AES-256-GCM.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
10
|
+
|
|
11
|
+
SALT = b"gcli-v1-salt-2024" # fixed salt (password provides secrecy)
|
|
12
|
+
ITERATIONS = 600_000 # OWASP 2023 recommendation for PBKDF2-SHA256
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def derive_key(password: str) -> bytes:
|
|
16
|
+
"""Derive a 256-bit key from a password using PBKDF2."""
|
|
17
|
+
return hashlib.pbkdf2_hmac(
|
|
18
|
+
"sha256",
|
|
19
|
+
password.encode("utf-8"),
|
|
20
|
+
SALT,
|
|
21
|
+
ITERATIONS,
|
|
22
|
+
dklen=32,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encrypt(plaintext: str, password: str) -> str:
|
|
27
|
+
"""Encrypt plaintext string -> base64 string. Format: nonce(12) + ciphertext + tag(16)."""
|
|
28
|
+
key = derive_key(password)
|
|
29
|
+
nonce = os.urandom(12)
|
|
30
|
+
aesgcm = AESGCM(key)
|
|
31
|
+
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
|
32
|
+
return base64.b64encode(nonce + ct).decode("ascii")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decrypt(blob: str, password: str) -> str:
|
|
36
|
+
"""Decrypt base64 string -> plaintext string."""
|
|
37
|
+
key = derive_key(password)
|
|
38
|
+
raw = base64.b64decode(blob)
|
|
39
|
+
nonce, ct = raw[:12], raw[12:]
|
|
40
|
+
aesgcm = AESGCM(key)
|
|
41
|
+
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def encrypt_dict(data: dict, password: str) -> str:
|
|
45
|
+
"""Encrypt a dict -> base64 string (JSON-serialised then encrypted)."""
|
|
46
|
+
return encrypt(json.dumps(data, separators=(",", ":")), password)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def decrypt_dict(blob: str, password: str) -> dict:
|
|
50
|
+
"""Decrypt a base64 string -> dict."""
|
|
51
|
+
return json.loads(decrypt(blob, password))
|