kache-ssh 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.
- kache/AGENTS.md +212 -0
- kache/__init__.py +17 -0
- kache/__main__.py +3 -0
- kache/agent.py +594 -0
- kache/cli.py +590 -0
- kache/client.py +352 -0
- kache/config.py +151 -0
- kache/evidence.py +112 -0
- kache/keys.py +42 -0
- kache/mcp.py +277 -0
- kache/network.py +148 -0
- kache/probe.py +176 -0
- kache/service.py +88 -0
- kache/telemetry.py +121 -0
- kache/tui.py +836 -0
- kache_ssh-0.1.0.dist-info/METADATA +174 -0
- kache_ssh-0.1.0.dist-info/RECORD +20 -0
- kache_ssh-0.1.0.dist-info/WHEEL +4 -0
- kache_ssh-0.1.0.dist-info/entry_points.txt +2 -0
- kache_ssh-0.1.0.dist-info/licenses/LICENSE +21 -0
kache/agent.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""The gatekeeper: Kache's agent on every device.
|
|
2
|
+
|
|
3
|
+
It is an SSH server (asyncssh) that only listens to paired devices:
|
|
4
|
+
* an unknown device may only say hello and ask to pair; the request pops
|
|
5
|
+
up as a yes/no question in this device's Kache terminal;
|
|
6
|
+
* a paired device may send orders. Every order goes into this device's
|
|
7
|
+
feed with the exact command and who sent it, runs in the device's own
|
|
8
|
+
shell, streams its output back, and when it fails the gatekeeper
|
|
9
|
+
collects evidence and sends it back to the sender;
|
|
10
|
+
* Kache terminals subscribe to telemetry (health numbers) and to the feed.
|
|
11
|
+
|
|
12
|
+
Internal requests are SSH exec commands starting with "kache:"; anything
|
|
13
|
+
else is an order and is executed exactly as written.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import codecs
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shlex
|
|
24
|
+
import shutil
|
|
25
|
+
import time
|
|
26
|
+
from collections import deque
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import asyncssh
|
|
31
|
+
import psutil
|
|
32
|
+
|
|
33
|
+
from . import PROTOCOL, SCHEMA, __version__
|
|
34
|
+
from .config import (Paths, device_settings, now_stamp, os_description, save_json,
|
|
35
|
+
save_trusted, trusted_keys)
|
|
36
|
+
from .evidence import collect, first_token, new_id
|
|
37
|
+
from .keys import fingerprint, load_or_create_key, pairing_code, public_key_text
|
|
38
|
+
from .network import advertised_addresses, listen_hosts, tailscale_status
|
|
39
|
+
from .telemetry import Telemetry
|
|
40
|
+
|
|
41
|
+
TRAILER = "\x1eKACHE " # last stderr line of an order: {"exit", "duration", "evidence", "cwd"}
|
|
42
|
+
POSIX_BUILTINS = {
|
|
43
|
+
".", ":", "[", "alias", "bg", "break", "builtin", "cd", "command", "continue", "declare", "echo", "eval",
|
|
44
|
+
"exec", "exit", "export", "false", "fg", "for", "function", "hash", "history", "if", "jobs", "kill",
|
|
45
|
+
"let", "local", "printf", "pwd", "read", "readonly", "return", "set", "shift", "source", "test", "time",
|
|
46
|
+
"times", "trap", "true", "type", "typeset", "ulimit", "umask", "unalias", "unset", "until", "wait", "while",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Peer:
|
|
52
|
+
fingerprint: str
|
|
53
|
+
public_key: str
|
|
54
|
+
addr: str
|
|
55
|
+
trusted: bool
|
|
56
|
+
own: bool
|
|
57
|
+
name: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def shell_for_os() -> str:
|
|
61
|
+
if os.name == "nt":
|
|
62
|
+
return shutil.which("pwsh") or shutil.which("powershell") or "powershell.exe"
|
|
63
|
+
return os.environ.get("SHELL") or "/bin/sh"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def shell_argv(shell: str, command: str, script_path: Path | None = None) -> list[str]:
|
|
67
|
+
if os.name == "nt":
|
|
68
|
+
# The order runs from a UTF-8 script file: no Windows quoting traps,
|
|
69
|
+
# and errors come out as text (-EncodedCommand turns them into CLIXML).
|
|
70
|
+
# UTF-8 output also switches the hidden console's code page, so native
|
|
71
|
+
# programs print å, ä, ö correctly. The tail turns a failing cmdlet or
|
|
72
|
+
# native program into a non-zero exit code.
|
|
73
|
+
script = ("$ProgressPreference='SilentlyContinue'\n"
|
|
74
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8\n"
|
|
75
|
+
"$OutputEncoding=[Text.Encoding]::UTF8\n"
|
|
76
|
+
f"{command}\n"
|
|
77
|
+
"$kacheOk=$?;if($LASTEXITCODE){exit $LASTEXITCODE}elseif(-not $kacheOk){exit 1}\n")
|
|
78
|
+
assert script_path is not None
|
|
79
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
script_path.write_text(script, encoding="utf-8-sig")
|
|
81
|
+
return [shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script_path)]
|
|
82
|
+
return [shell, "-c", command]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def order_environment() -> dict:
|
|
86
|
+
env = dict(os.environ)
|
|
87
|
+
env.setdefault("PYTHONIOENCODING", "utf-8") # Python writes UTF-8 to the pipe, not the ANSI code page
|
|
88
|
+
env.setdefault("PYTHONUNBUFFERED", "1") # and prints show up live, not in 8 KB blocks
|
|
89
|
+
return env
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def kill_tree(pid: int) -> None:
|
|
93
|
+
try:
|
|
94
|
+
parent = psutil.Process(pid)
|
|
95
|
+
except psutil.Error:
|
|
96
|
+
return
|
|
97
|
+
for proc in parent.children(recursive=True) + [parent]:
|
|
98
|
+
try:
|
|
99
|
+
proc.kill()
|
|
100
|
+
except psutil.Error:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Server(asyncssh.SSHServer):
|
|
105
|
+
"""One per connection; remembers which key authenticated it."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, gatekeeper: "Gatekeeper") -> None:
|
|
108
|
+
self.gk = gatekeeper
|
|
109
|
+
self.conn = None
|
|
110
|
+
self.peer: Peer | None = None
|
|
111
|
+
|
|
112
|
+
def connection_made(self, conn) -> None:
|
|
113
|
+
self.conn = conn
|
|
114
|
+
self.gk.connections.add(conn)
|
|
115
|
+
|
|
116
|
+
def connection_lost(self, exc) -> None:
|
|
117
|
+
self.gk.connections.discard(self.conn)
|
|
118
|
+
|
|
119
|
+
def begin_auth(self, username: str) -> bool:
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
def public_key_auth_supported(self) -> bool:
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
|
|
126
|
+
# Every key may connect; what it may do is decided per request.
|
|
127
|
+
fp = key.get_fingerprint("sha256")
|
|
128
|
+
entry = self.gk.trusted.get(fp)
|
|
129
|
+
own = fp == self.gk.fingerprint
|
|
130
|
+
peername = self.conn.get_extra_info("peername") or ("?", 0)
|
|
131
|
+
self.peer = Peer(
|
|
132
|
+
fingerprint=fp, public_key=key.export_public_key().decode("ascii").strip(),
|
|
133
|
+
addr=str(peername[0]), trusted=own or entry is not None, own=own,
|
|
134
|
+
name="this device" if own else (entry or {}).get("name", "unknown"))
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Gatekeeper:
|
|
139
|
+
def __init__(self, paths: Paths, port: int | None = None, host: str | None = None, log=None) -> None:
|
|
140
|
+
self.paths = paths
|
|
141
|
+
self.key = load_or_create_key(paths.key_file)
|
|
142
|
+
self.fingerprint = fingerprint(self.key)
|
|
143
|
+
settings = device_settings(paths)
|
|
144
|
+
self.name = settings["name"]
|
|
145
|
+
self.port = settings["port"] if port is None else port
|
|
146
|
+
self.fixed_host = host # tests and `kache agent --host` pin one interface
|
|
147
|
+
self.listen = settings.get("listen", "all")
|
|
148
|
+
self.hosts: list[str] = []
|
|
149
|
+
self.addresses: list[str] = []
|
|
150
|
+
self.tailscale: dict | None = None
|
|
151
|
+
self.connections: set = set()
|
|
152
|
+
self.trusted = trusted_keys(paths)
|
|
153
|
+
self.telemetry = Telemetry(str(Path.home().anchor or "/"))
|
|
154
|
+
self.shell = shell_for_os()
|
|
155
|
+
self.log = log or (lambda text: None)
|
|
156
|
+
self.subscribers: set[asyncio.Queue] = set()
|
|
157
|
+
self.history: deque = deque(maxlen=300)
|
|
158
|
+
self.pending: dict[str, dict] = {}
|
|
159
|
+
self.sessions: dict[tuple[str, str], str] = {}
|
|
160
|
+
self.server = None
|
|
161
|
+
self.stopped = asyncio.Event()
|
|
162
|
+
self._tasks: list[asyncio.Task] = []
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------- life
|
|
165
|
+
async def start(self) -> "Gatekeeper":
|
|
166
|
+
await self._refresh_network()
|
|
167
|
+
await self._bind()
|
|
168
|
+
self.telemetry.latest = await asyncio.to_thread(self.telemetry.sample)
|
|
169
|
+
self._tasks.append(asyncio.create_task(self.telemetry.run()))
|
|
170
|
+
self._tasks.append(asyncio.create_task(self._watch_network()))
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
async def _bind(self) -> None:
|
|
174
|
+
hosts = [self.fixed_host] if self.fixed_host else listen_hosts(self.listen, self.tailscale)
|
|
175
|
+
if self.server is not None:
|
|
176
|
+
self.server.close() # open connections stay up; only new ones use the new interfaces
|
|
177
|
+
self.server = await asyncssh.create_server(
|
|
178
|
+
lambda: _Server(self), hosts if len(hosts) > 1 else hosts[0], self.port,
|
|
179
|
+
server_host_keys=[self.key], process_factory=self._handle,
|
|
180
|
+
encoding="utf-8", allow_scp=False, agent_forwarding=False,
|
|
181
|
+
x11_forwarding=False, keepalive_interval=15, line_editor=False)
|
|
182
|
+
if self.port == 0:
|
|
183
|
+
self.port = self.server.get_port() or self.server.sockets[0].getsockname()[1]
|
|
184
|
+
self.hosts = hosts
|
|
185
|
+
self.log(f"gatekeeper '{self.name}' listening on {', '.join(hosts)} port {self.port} ({self.fingerprint})")
|
|
186
|
+
|
|
187
|
+
async def _refresh_network(self) -> None:
|
|
188
|
+
if self.fixed_host in ("127.0.0.1", "localhost", "::1"):
|
|
189
|
+
return # a local-only gatekeeper (tests) has nothing to advertise
|
|
190
|
+
self.tailscale = await asyncio.to_thread(tailscale_status)
|
|
191
|
+
self.addresses = await asyncio.to_thread(advertised_addresses, self.tailscale)
|
|
192
|
+
|
|
193
|
+
async def _watch_network(self) -> None:
|
|
194
|
+
"""Addresses change (new Wi-Fi, Tailscale coming up after logon).
|
|
195
|
+
Keep the advertised list fresh, and in Tailscale-only mode bind to the
|
|
196
|
+
tailnet as soon as it appears."""
|
|
197
|
+
while True:
|
|
198
|
+
await asyncio.sleep(60)
|
|
199
|
+
try:
|
|
200
|
+
await self._refresh_network()
|
|
201
|
+
if not self.fixed_host and listen_hosts(self.listen, self.tailscale) != self.hosts:
|
|
202
|
+
await self._bind()
|
|
203
|
+
except Exception as error: # a network hiccup must not stop the gatekeeper
|
|
204
|
+
self.log(f"network refresh failed: {error!r}")
|
|
205
|
+
|
|
206
|
+
async def stop(self) -> None:
|
|
207
|
+
for task in self._tasks:
|
|
208
|
+
task.cancel()
|
|
209
|
+
if self.server:
|
|
210
|
+
self.server.close()
|
|
211
|
+
for conn in list(self.connections):
|
|
212
|
+
conn.close()
|
|
213
|
+
if self.server:
|
|
214
|
+
try:
|
|
215
|
+
await asyncio.wait_for(self.server.wait_closed(), 5)
|
|
216
|
+
except asyncio.TimeoutError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------- feed
|
|
220
|
+
def emit(self, event: dict) -> None:
|
|
221
|
+
event = {"v": SCHEMA, "time": time.time(), "device": self.name, "device_fp": self.fingerprint, **event}
|
|
222
|
+
self.history.append(event)
|
|
223
|
+
try:
|
|
224
|
+
if self.paths.feed_file.exists() and self.paths.feed_file.stat().st_size > 5_000_000:
|
|
225
|
+
self.paths.feed_file.replace(self.paths.feed_file.with_suffix(".old.jsonl"))
|
|
226
|
+
with self.paths.feed_file.open("a", encoding="utf-8") as handle:
|
|
227
|
+
handle.write(json.dumps(event) + "\n")
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
for queue in list(self.subscribers):
|
|
231
|
+
if queue.full():
|
|
232
|
+
continue
|
|
233
|
+
queue.put_nowait(event)
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------- requests
|
|
236
|
+
async def _handle(self, process) -> None:
|
|
237
|
+
conn = process.get_extra_info("connection")
|
|
238
|
+
owner = conn.get_owner() if conn is not None else None
|
|
239
|
+
peer = getattr(owner, "peer", None)
|
|
240
|
+
command = process.command
|
|
241
|
+
try:
|
|
242
|
+
if peer is None:
|
|
243
|
+
process.exit(1)
|
|
244
|
+
return
|
|
245
|
+
if command is None:
|
|
246
|
+
process.stderr.write("Kache sends one command at a time; interactive shells come later.\n")
|
|
247
|
+
process.exit(2)
|
|
248
|
+
return
|
|
249
|
+
if command.startswith("kache:"):
|
|
250
|
+
verb, _, argument = command[len("kache:"):].partition(" ")
|
|
251
|
+
await self._internal(verb, argument, peer, process)
|
|
252
|
+
elif not peer.trusted:
|
|
253
|
+
self._refuse(process)
|
|
254
|
+
else:
|
|
255
|
+
await self._order({"cmd": command}, peer, process)
|
|
256
|
+
except (BrokenPipeError, ConnectionError, asyncssh.Error):
|
|
257
|
+
pass
|
|
258
|
+
except Exception as error: # never let one request kill the gatekeeper
|
|
259
|
+
self.log(f"request failed: {error!r}")
|
|
260
|
+
try:
|
|
261
|
+
process.stderr.write(f"Kache gatekeeper error: {error}\n")
|
|
262
|
+
process.exit(1)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
def _refuse(self, process) -> None:
|
|
267
|
+
process.stderr.write("This device has not been paired with you. Send a pair request first.\n")
|
|
268
|
+
process.exit(77)
|
|
269
|
+
|
|
270
|
+
async def _internal(self, verb: str, argument: str, peer: Peer, process) -> None:
|
|
271
|
+
if verb == "hello":
|
|
272
|
+
self._reply(process, self.hello(peer))
|
|
273
|
+
return
|
|
274
|
+
if verb == "pair":
|
|
275
|
+
await self._pair(peer, process, argument)
|
|
276
|
+
return
|
|
277
|
+
if not peer.trusted:
|
|
278
|
+
self._refuse(process)
|
|
279
|
+
return
|
|
280
|
+
if verb == "order":
|
|
281
|
+
await self._order(json.loads(argument or "{}"), peer, process)
|
|
282
|
+
elif verb == "telemetry":
|
|
283
|
+
await self._stream(process, telemetry=True)
|
|
284
|
+
elif verb == "feed":
|
|
285
|
+
await self._stream(process, telemetry=False)
|
|
286
|
+
elif verb == "session":
|
|
287
|
+
data = json.loads(argument or "{}")
|
|
288
|
+
self._reply(process, {"cwd": self.sessions.get((peer.fingerprint, data.get("session", "default")), str(Path.home()))})
|
|
289
|
+
elif verb == "evidence":
|
|
290
|
+
path = self.paths.evidence_dir / f"{Path(argument.strip()).name}.json"
|
|
291
|
+
if path.exists():
|
|
292
|
+
process.stdout.write(path.read_text(encoding="utf-8"))
|
|
293
|
+
process.exit(0)
|
|
294
|
+
else:
|
|
295
|
+
process.stderr.write("No such evidence on this device.\n")
|
|
296
|
+
process.exit(1)
|
|
297
|
+
elif verb == "forget":
|
|
298
|
+
removed = self.trusted.pop(peer.fingerprint, None)
|
|
299
|
+
save_trusted(self.paths, self.trusted)
|
|
300
|
+
if removed:
|
|
301
|
+
self.emit({"kind": "unpaired", "sender": self._sender(peer), "text": f"{removed.get('name')} removed itself"})
|
|
302
|
+
self._reply(process, {"removed": bool(removed)})
|
|
303
|
+
elif peer.own:
|
|
304
|
+
await self._local(verb, argument, peer, process)
|
|
305
|
+
else:
|
|
306
|
+
process.stderr.write(f"Unknown or local-only request: kache:{verb}\n")
|
|
307
|
+
process.exit(1)
|
|
308
|
+
|
|
309
|
+
async def _local(self, verb: str, argument: str, peer: Peer, process) -> None:
|
|
310
|
+
"""Requests only this device's own Kache terminal may make."""
|
|
311
|
+
if verb == "pending":
|
|
312
|
+
self._reply(process, [entry["info"] for entry in self.pending.values()])
|
|
313
|
+
elif verb == "decide":
|
|
314
|
+
data = json.loads(argument or "{}")
|
|
315
|
+
entry = self.pending.get(data.get("id", ""))
|
|
316
|
+
if entry and not entry["future"].done():
|
|
317
|
+
entry["future"].set_result(bool(data.get("accept")))
|
|
318
|
+
self._reply(process, {"ok": True})
|
|
319
|
+
else:
|
|
320
|
+
self._reply(process, {"ok": False, "reason": "no such pending request"})
|
|
321
|
+
elif verb == "trusted":
|
|
322
|
+
self._reply(process, self.trusted)
|
|
323
|
+
elif verb == "revoke":
|
|
324
|
+
target = argument.strip()
|
|
325
|
+
removed = [fp for fp, entry in self.trusted.items() if fp == target or entry.get("name") == target]
|
|
326
|
+
for fp in removed:
|
|
327
|
+
self.trusted.pop(fp, None)
|
|
328
|
+
save_trusted(self.paths, self.trusted)
|
|
329
|
+
for fp in removed:
|
|
330
|
+
self.emit({"kind": "unpaired", "sender": self._sender(peer), "text": f"revoked {fp}"})
|
|
331
|
+
self._reply(process, {"removed": removed})
|
|
332
|
+
elif verb == "reload":
|
|
333
|
+
settings = device_settings(self.paths)
|
|
334
|
+
self.name = settings["name"]
|
|
335
|
+
self.trusted = trusted_keys(self.paths)
|
|
336
|
+
listen = settings.get("listen", "all")
|
|
337
|
+
if listen != self.listen:
|
|
338
|
+
self.listen = listen
|
|
339
|
+
await self._refresh_network()
|
|
340
|
+
await self._bind()
|
|
341
|
+
self._reply(process, {"name": self.name, "listen": self.listen, "hosts": self.hosts})
|
|
342
|
+
elif verb == "shutdown":
|
|
343
|
+
self._reply(process, {"ok": True})
|
|
344
|
+
self.stopped.set()
|
|
345
|
+
else:
|
|
346
|
+
process.stderr.write(f"Unknown request: kache:{verb}\n")
|
|
347
|
+
process.exit(1)
|
|
348
|
+
|
|
349
|
+
def _reply(self, process, data) -> None:
|
|
350
|
+
process.stdout.write(json.dumps(data) + "\n")
|
|
351
|
+
process.exit(0)
|
|
352
|
+
|
|
353
|
+
def hello(self, peer: Peer | None = None) -> dict:
|
|
354
|
+
return {"name": self.name, "fingerprint": self.fingerprint, "public_key": public_key_text(self.key),
|
|
355
|
+
"os": os_description(), "kache": __version__, "proto": PROTOCOL, "schema": SCHEMA,
|
|
356
|
+
"port": self.port, "addresses": self.addresses, "listen": self.listen,
|
|
357
|
+
"paired": bool(peer and peer.trusted), "own": bool(peer and peer.own)}
|
|
358
|
+
|
|
359
|
+
def _sender(self, peer: Peer, label: str | None = None, via: str | None = None) -> dict:
|
|
360
|
+
label = str(label)[:40] if label else None
|
|
361
|
+
via = str(via)[:20] if via else None
|
|
362
|
+
return {"name": peer.name, "label": label, "via": via, "fingerprint": peer.fingerprint, "addr": peer.addr}
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------- streams
|
|
365
|
+
async def _stream(self, process, telemetry: bool) -> None:
|
|
366
|
+
closed = asyncio.ensure_future(process.channel.wait_closed())
|
|
367
|
+
queue: asyncio.Queue | None = None
|
|
368
|
+
try:
|
|
369
|
+
if telemetry:
|
|
370
|
+
while not closed.done():
|
|
371
|
+
process.stdout.write(json.dumps(self.telemetry.latest) + "\n")
|
|
372
|
+
await asyncio.wait({closed}, timeout=2.0)
|
|
373
|
+
return
|
|
374
|
+
queue = asyncio.Queue(maxsize=2000)
|
|
375
|
+
self.subscribers.add(queue)
|
|
376
|
+
for event in list(self.history)[-60:]:
|
|
377
|
+
process.stdout.write(json.dumps({**event, "replay": True}) + "\n")
|
|
378
|
+
while not closed.done():
|
|
379
|
+
getter = asyncio.ensure_future(queue.get())
|
|
380
|
+
done, _ = await asyncio.wait({getter, closed}, return_when=asyncio.FIRST_COMPLETED)
|
|
381
|
+
if getter in done:
|
|
382
|
+
process.stdout.write(json.dumps(getter.result()) + "\n")
|
|
383
|
+
else:
|
|
384
|
+
getter.cancel()
|
|
385
|
+
finally:
|
|
386
|
+
if queue is not None:
|
|
387
|
+
self.subscribers.discard(queue)
|
|
388
|
+
closed.cancel()
|
|
389
|
+
|
|
390
|
+
# ---------------------------------------------------------------- pairing
|
|
391
|
+
async def _pair(self, peer: Peer, process, argument: str) -> None:
|
|
392
|
+
request = json.loads(argument or "{}")
|
|
393
|
+
answer = {"name": self.name, "fingerprint": self.fingerprint, "host_key": public_key_text(self.key),
|
|
394
|
+
"os": os_description(), "kache": __version__, "proto": PROTOCOL,
|
|
395
|
+
"addresses": self.addresses, "port": self.port,
|
|
396
|
+
"code": pairing_code(peer.fingerprint, self.fingerprint)}
|
|
397
|
+
if peer.own:
|
|
398
|
+
self._reply(process, {**answer, "accepted": False, "reason": "a device cannot pair with itself"})
|
|
399
|
+
return
|
|
400
|
+
if peer.trusted:
|
|
401
|
+
self._reply(process, {**answer, "accepted": True, "already": True})
|
|
402
|
+
return
|
|
403
|
+
if request.get("proto") != PROTOCOL:
|
|
404
|
+
self._reply(process, {**answer, "accepted": False,
|
|
405
|
+
"reason": f"Kache versions differ (this device speaks protocol {PROTOCOL}); update both with pip install -U kache-ssh"})
|
|
406
|
+
return
|
|
407
|
+
if len(self.pending) >= 5 or any(e["info"]["fingerprint"] == peer.fingerprint for e in self.pending.values()):
|
|
408
|
+
self._reply(process, {**answer, "accepted": False, "reason": "a request from you is already waiting"})
|
|
409
|
+
return
|
|
410
|
+
info = {"id": new_id("P"), "name": str(request.get("name") or "unknown")[:40], "os": request.get("os"),
|
|
411
|
+
"fingerprint": peer.fingerprint, "addr": peer.addr, "code": answer["code"], "created": time.time()}
|
|
412
|
+
future = asyncio.get_running_loop().create_future()
|
|
413
|
+
self.pending[info["id"]] = {"info": info, "future": future, "public_key": peer.public_key}
|
|
414
|
+
self.emit({"kind": "pair-request", **info})
|
|
415
|
+
try:
|
|
416
|
+
accepted = await asyncio.wait_for(asyncio.shield(future), timeout=180)
|
|
417
|
+
reason = None if accepted else "declined"
|
|
418
|
+
except asyncio.TimeoutError:
|
|
419
|
+
accepted, reason = False, "nobody answered on that device within 3 minutes"
|
|
420
|
+
finally:
|
|
421
|
+
self.pending.pop(info["id"], None)
|
|
422
|
+
if accepted:
|
|
423
|
+
self.trusted[peer.fingerprint] = {"name": info["name"], "public_key": peer.public_key, "os": info["os"],
|
|
424
|
+
"addr": peer.addr, "added": now_stamp()}
|
|
425
|
+
save_trusted(self.paths, self.trusted)
|
|
426
|
+
peer.trusted, peer.name = True, info["name"]
|
|
427
|
+
self.emit({"kind": "paired", "name": info["name"], "fingerprint": peer.fingerprint, "addr": peer.addr})
|
|
428
|
+
else:
|
|
429
|
+
self.emit({"kind": "pair-declined", "name": info["name"], "fingerprint": peer.fingerprint, "reason": reason})
|
|
430
|
+
self._reply(process, {**answer, "accepted": accepted, "reason": reason})
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------- orders
|
|
433
|
+
async def _order(self, order: dict, peer: Peer, process) -> None:
|
|
434
|
+
command = str(order.get("cmd", "")).strip()
|
|
435
|
+
order_id = order.get("id") or new_id("O")
|
|
436
|
+
session = str(order.get("session") or "default")
|
|
437
|
+
sender = self._sender(peer, order.get("label"), order.get("via"))
|
|
438
|
+
key = (peer.fingerprint, session)
|
|
439
|
+
cwd = self.sessions.get(key) or str(Path.home())
|
|
440
|
+
order = {"id": order_id, "cmd": command, "sender": sender, "cwd": cwd, "session": session}
|
|
441
|
+
self.emit({"kind": "order", **order})
|
|
442
|
+
started = time.monotonic()
|
|
443
|
+
|
|
444
|
+
last_char = {"stdout": "\n", "stderr": "\n"}
|
|
445
|
+
|
|
446
|
+
def finish(code: int, evidence: dict | None = None, rejected: str | None = None) -> None:
|
|
447
|
+
duration = time.monotonic() - started
|
|
448
|
+
event = {"kind": "done", "id": order_id, "cmd": command, "sender": sender,
|
|
449
|
+
"exit": code, "duration": round(duration, 3), "session": session}
|
|
450
|
+
if evidence:
|
|
451
|
+
event["evidence"] = evidence["id"]
|
|
452
|
+
event["summary"] = evidence["summary"]
|
|
453
|
+
if rejected:
|
|
454
|
+
event["rejected"] = rejected
|
|
455
|
+
self.emit(event)
|
|
456
|
+
trailer = {"exit": code, "duration": round(duration, 3), "cwd": self.sessions.get(key, cwd),
|
|
457
|
+
"evidence": evidence["id"] if evidence else None, "summary": evidence["summary"] if evidence else None,
|
|
458
|
+
"rejected": rejected}
|
|
459
|
+
newline = "" if last_char["stderr"] == "\n" else "\n" # the trailer must start its own line
|
|
460
|
+
process.stderr.write(newline + TRAILER + json.dumps(trailer) + "\n")
|
|
461
|
+
process.exit(0 if code == 0 else (code if 0 < code < 256 else 255))
|
|
462
|
+
|
|
463
|
+
if "proto" in order and order.get("proto") != PROTOCOL:
|
|
464
|
+
reason = f"Kache versions differ (sender protocol {order.get('proto')}, this device {PROTOCOL}). Update both."
|
|
465
|
+
process.stderr.write(reason + "\n")
|
|
466
|
+
finish(126, rejected=reason)
|
|
467
|
+
return
|
|
468
|
+
if not command:
|
|
469
|
+
finish(0)
|
|
470
|
+
return
|
|
471
|
+
cd = self._try_cd(command, key, cwd)
|
|
472
|
+
if cd is not None:
|
|
473
|
+
ok, message = cd
|
|
474
|
+
if message:
|
|
475
|
+
process.stderr.write(message)
|
|
476
|
+
finish(0 if ok else 1)
|
|
477
|
+
return
|
|
478
|
+
refusal = self._precheck(command)
|
|
479
|
+
if refusal:
|
|
480
|
+
process.stderr.write(refusal + "\n")
|
|
481
|
+
result = {"exit": 127, "duration": 0.0, "stdout_tail": [], "stderr_tail": [refusal], "cancelled": False}
|
|
482
|
+
finish(127, self._evidence(order, result), rejected=refusal)
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
script = self.paths.home / "orders" / f"{re.sub(r'[^A-Za-z0-9_-]', '', str(order_id))[:40] or new_id('O')}.ps1"
|
|
486
|
+
try:
|
|
487
|
+
await self._run_child(command, cwd, script, process, order, order_id, sender, started, finish, last_char)
|
|
488
|
+
finally:
|
|
489
|
+
if os.name == "nt":
|
|
490
|
+
try:
|
|
491
|
+
script.unlink()
|
|
492
|
+
except OSError:
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
async def _run_child(self, command, cwd, script, process, order, order_id, sender, started, finish, last_char) -> None:
|
|
496
|
+
child = await asyncio.create_subprocess_exec(
|
|
497
|
+
*shell_argv(self.shell, command, script), cwd=cwd, stdin=asyncio.subprocess.DEVNULL,
|
|
498
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=order_environment(),
|
|
499
|
+
creationflags=0x08000000 if os.name == "nt" else 0) # CREATE_NO_WINDOW: a hidden console
|
|
500
|
+
tails = {"stdout": deque(maxlen=60), "stderr": deque(maxlen=60)}
|
|
501
|
+
|
|
502
|
+
async def pump(stream, out, name: str) -> None:
|
|
503
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
504
|
+
partial = ""
|
|
505
|
+
while True:
|
|
506
|
+
chunk = await stream.read(4096)
|
|
507
|
+
if not chunk:
|
|
508
|
+
break
|
|
509
|
+
text = decoder.decode(chunk)
|
|
510
|
+
if not text:
|
|
511
|
+
continue
|
|
512
|
+
last_char[name] = text[-1]
|
|
513
|
+
try:
|
|
514
|
+
out.write(text)
|
|
515
|
+
except (BrokenPipeError, ConnectionError, asyncssh.Error):
|
|
516
|
+
pass
|
|
517
|
+
partial += text
|
|
518
|
+
*lines, partial = partial.split("\n")
|
|
519
|
+
tails[name].extend(line.rstrip("\r") for line in lines)
|
|
520
|
+
if partial:
|
|
521
|
+
tails[name].append(partial.rstrip("\r"))
|
|
522
|
+
|
|
523
|
+
pumps = asyncio.ensure_future(asyncio.gather(pump(child.stdout, process.stdout, "stdout"),
|
|
524
|
+
pump(child.stderr, process.stderr, "stderr")))
|
|
525
|
+
closed = asyncio.ensure_future(process.channel.wait_closed())
|
|
526
|
+
cancelled = False
|
|
527
|
+
done, _ = await asyncio.wait({pumps, closed}, return_when=asyncio.FIRST_COMPLETED)
|
|
528
|
+
if pumps not in done:
|
|
529
|
+
cancelled = True # the sender hung up (Ctrl+C on their side)
|
|
530
|
+
kill_tree(child.pid)
|
|
531
|
+
await pumps
|
|
532
|
+
closed.cancel()
|
|
533
|
+
code = await child.wait()
|
|
534
|
+
result = {"exit": code, "duration": time.monotonic() - started, "stdout_tail": list(tails["stdout"]),
|
|
535
|
+
"stderr_tail": list(tails["stderr"]), "cancelled": cancelled}
|
|
536
|
+
evidence = self._evidence(order, result) if (code != 0 or cancelled) else None
|
|
537
|
+
if cancelled:
|
|
538
|
+
self.emit({"kind": "done", "id": order_id, "cmd": command, "sender": sender, "exit": code,
|
|
539
|
+
"duration": round(result["duration"], 3), "cancelled": True, "session": order["session"],
|
|
540
|
+
"evidence": evidence["id"] if evidence else None,
|
|
541
|
+
"summary": evidence["summary"] if evidence else None})
|
|
542
|
+
return
|
|
543
|
+
finish(code, evidence)
|
|
544
|
+
|
|
545
|
+
def _evidence(self, order: dict, result: dict) -> dict:
|
|
546
|
+
bundle = collect(order, result, self.telemetry.latest, self.name, self.shell)
|
|
547
|
+
save_json(self.paths.evidence_dir / f"{bundle['id']}.json", bundle)
|
|
548
|
+
self.emit({"kind": "evidence", "id": bundle["id"], "order": order["id"], "cmd": order["cmd"],
|
|
549
|
+
"sender": order["sender"], "summary": bundle["summary"], "exit": bundle["exit"]})
|
|
550
|
+
return bundle
|
|
551
|
+
|
|
552
|
+
def _try_cd(self, command: str, key, cwd: str):
|
|
553
|
+
"""`cd` moves the sender's session, so the next order starts there,
|
|
554
|
+
just like typing in a real terminal on this device."""
|
|
555
|
+
match = re.fullmatch(r"(cd|chdir|Set-Location|sl)(?:\s+(.+))?", command.strip(), re.I)
|
|
556
|
+
if not match:
|
|
557
|
+
return None
|
|
558
|
+
argument = (match.group(2) or "").strip().strip("\"'")
|
|
559
|
+
if not argument or argument == "~":
|
|
560
|
+
target = Path.home()
|
|
561
|
+
else:
|
|
562
|
+
target = Path(os.path.expandvars(os.path.expanduser(argument)))
|
|
563
|
+
if not target.is_absolute():
|
|
564
|
+
target = Path(cwd) / target
|
|
565
|
+
try:
|
|
566
|
+
target = target.resolve()
|
|
567
|
+
except OSError:
|
|
568
|
+
pass
|
|
569
|
+
if target.is_dir():
|
|
570
|
+
self.sessions[key] = str(target)
|
|
571
|
+
return True, ""
|
|
572
|
+
return False, f"cd: no such folder on {self.name}: {argument}\n"
|
|
573
|
+
|
|
574
|
+
def _precheck(self, command: str) -> str | None:
|
|
575
|
+
"""Refuse orders that cannot work here, before running anything.
|
|
576
|
+
PowerShell resolves cmdlets and aliases itself, so on Windows the
|
|
577
|
+
gatekeeper lets the shell decide and explains the failure afterwards."""
|
|
578
|
+
if os.name == "nt":
|
|
579
|
+
return None
|
|
580
|
+
token = first_token(command)
|
|
581
|
+
if not token or token in POSIX_BUILTINS or "/" in token or "=" in token or token.startswith(("(", "{", "$")):
|
|
582
|
+
return None
|
|
583
|
+
if shutil.which(token):
|
|
584
|
+
return None
|
|
585
|
+
return f"Refused by the gatekeeper on {self.name}: '{token}' is not installed here ({os_description()})."
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
async def run_agent(paths: Paths, port: int | None = None, host: str | None = None) -> None:
|
|
589
|
+
gatekeeper = await Gatekeeper(paths, port=port, host=host, log=lambda t: print(t, flush=True)).start()
|
|
590
|
+
print(f"Kache gatekeeper for '{gatekeeper.name}' is running on port {gatekeeper.port}. Ctrl+C stops it.", flush=True)
|
|
591
|
+
try:
|
|
592
|
+
await gatekeeper.stopped.wait()
|
|
593
|
+
finally:
|
|
594
|
+
await gatekeeper.stop()
|