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/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 @@
|
|
|
1
|
+
gcli
|