814 lines
35 KiB
Python
814 lines
35 KiB
Python
|
|
#!/usr/bin/python3
|
||
|
|
"""
|
||
|
|
SetecSuite — Camera MITM Tool (PyQt6 GUI)
|
||
|
|
Usage: sudo /usr/bin/python3 gui.py
|
||
|
|
"""
|
||
|
|
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import json
|
||
|
|
import threading
|
||
|
|
import signal
|
||
|
|
|
||
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
|
||
|
|
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject
|
||
|
|
from PyQt6.QtGui import QFont, QTextCursor, QColor, QPalette, QAction
|
||
|
|
from PyQt6.QtWidgets import (
|
||
|
|
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout,
|
||
|
|
QPushButton, QPlainTextEdit, QLabel, QLineEdit, QTableWidget,
|
||
|
|
QTableWidgetItem, QHeaderView, QFormLayout, QGroupBox, QComboBox,
|
||
|
|
QStatusBar, QMessageBox, QSplitter, QTextEdit, QAbstractItemView,
|
||
|
|
QCheckBox,
|
||
|
|
)
|
||
|
|
|
||
|
|
from config import Config
|
||
|
|
from utils.log import (
|
||
|
|
log, log_lines, init_logfile, close_logfile, lock,
|
||
|
|
C_NONE, C_ERROR, C_SUCCESS, C_INFO, C_TRAFFIC, C_IMPORTANT,
|
||
|
|
)
|
||
|
|
from services import arp_spoof, dns_spoof, http_server, udp_listener, sniffer, intruder_watch
|
||
|
|
from utils import proto as proto_id
|
||
|
|
from api import ubox_client, server as rest_server, fuzzer, firmware_fetch, cve_checks, ota_bucket_probe
|
||
|
|
from api.fuzzer import KNOWN_ENDPOINTS
|
||
|
|
from inject import packet
|
||
|
|
from mitm import Controller
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Color map (Qt) ──────────────────────────────────────────────
|
||
|
|
QT_COLORS = {
|
||
|
|
C_NONE: QColor("#cccccc"),
|
||
|
|
C_ERROR: QColor("#ff5555"),
|
||
|
|
C_SUCCESS: QColor("#50fa7b"),
|
||
|
|
C_INFO: QColor("#8be9fd"),
|
||
|
|
C_TRAFFIC: QColor("#f1fa8c"),
|
||
|
|
C_IMPORTANT: QColor("#ff79c6"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Bridge: log_lines deque -> Qt signal ────────────────────────
|
||
|
|
class LogBridge(QObject):
|
||
|
|
new_lines = pyqtSignal(list) # list of (line, color)
|
||
|
|
|
||
|
|
|
||
|
|
class CloudBridge(QObject):
|
||
|
|
response = pyqtSignal(str, object) # label, payload
|
||
|
|
|
||
|
|
|
||
|
|
class CveBridge(QObject):
|
||
|
|
result = pyqtSignal(str, object) # cve_id, result dict
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Main Window ─────────────────────────────────────────────────
|
||
|
|
class MainWindow(QMainWindow):
|
||
|
|
def __init__(self, ctrl):
|
||
|
|
super().__init__()
|
||
|
|
self.ctrl = ctrl
|
||
|
|
self.setWindowTitle("SetecSuite — Camera MITM")
|
||
|
|
self.resize(1400, 900)
|
||
|
|
self._apply_dark_theme()
|
||
|
|
|
||
|
|
self.bridge = LogBridge()
|
||
|
|
self.bridge.new_lines.connect(self._append_log)
|
||
|
|
self._last_log_idx = 0
|
||
|
|
|
||
|
|
self.cloud_bridge = CloudBridge()
|
||
|
|
self.cloud_bridge.response.connect(self._cloud_set_response)
|
||
|
|
self.cloud_response_signal = self.cloud_bridge.response
|
||
|
|
|
||
|
|
self.cve_bridge = CveBridge()
|
||
|
|
self.cve_bridge.result.connect(self._cve_set_status)
|
||
|
|
self.cve_signal = self.cve_bridge.result
|
||
|
|
|
||
|
|
self.tabs = QTabWidget()
|
||
|
|
self.tabs.addTab(self._build_dashboard(), "Dashboard")
|
||
|
|
self.tabs.addTab(self._build_log_tab(), "Live Log")
|
||
|
|
self.tabs.addTab(self._build_intruder_tab(), "Intruders")
|
||
|
|
self.tabs.addTab(self._build_cloud_tab(), "Cloud API")
|
||
|
|
self.tabs.addTab(self._build_fuzzer_tab(), "Fuzzer")
|
||
|
|
self.tabs.addTab(self._build_inject_tab(), "Inject")
|
||
|
|
self.tabs.addTab(self._build_cve_tab(), "CVEs")
|
||
|
|
self.tabs.addTab(self._build_config_tab(), "Config")
|
||
|
|
self.tabs.addTab(self._build_help_tab(), "Help")
|
||
|
|
self.setCentralWidget(self.tabs)
|
||
|
|
|
||
|
|
self.status = QStatusBar()
|
||
|
|
self.setStatusBar(self.status)
|
||
|
|
|
||
|
|
# Periodic UI refresh
|
||
|
|
self.refresh_timer = QTimer(self)
|
||
|
|
self.refresh_timer.timeout.connect(self._tick)
|
||
|
|
self.refresh_timer.start(300)
|
||
|
|
|
||
|
|
# Start REST API
|
||
|
|
threading.Thread(
|
||
|
|
target=rest_server.start_server,
|
||
|
|
args=(self.ctrl, self.ctrl.cfg["rest_port"]),
|
||
|
|
daemon=True,
|
||
|
|
).start()
|
||
|
|
|
||
|
|
log("SetecSuite GUI ready", C_SUCCESS)
|
||
|
|
log(f"Target: {ctrl.cfg['camera_ip']} Us: {ctrl.cfg['our_ip']} Router: {ctrl.cfg['router_ip']}", C_INFO)
|
||
|
|
|
||
|
|
# ── Theme ────────────────────────────────────────────────────
|
||
|
|
def _apply_dark_theme(self):
|
||
|
|
self.setStyleSheet("""
|
||
|
|
QWidget { background: #1e1f29; color: #f8f8f2; font-family: 'JetBrains Mono','Fira Code',monospace; font-size: 11pt; }
|
||
|
|
QTabWidget::pane { border: 1px solid #44475a; }
|
||
|
|
QTabBar::tab { background: #282a36; padding: 8px 16px; border: 1px solid #44475a; }
|
||
|
|
QTabBar::tab:selected { background: #44475a; color: #50fa7b; }
|
||
|
|
QPushButton { background: #44475a; border: 1px solid #6272a4; padding: 6px 14px; border-radius: 3px; }
|
||
|
|
QPushButton:hover { background: #6272a4; }
|
||
|
|
QPushButton:pressed { background: #50fa7b; color: #282a36; }
|
||
|
|
QPlainTextEdit, QTextEdit, QLineEdit { background: #282a36; border: 1px solid #44475a; selection-background-color: #44475a; }
|
||
|
|
QHeaderView::section { background: #44475a; color: #f8f8f2; padding: 4px; border: none; }
|
||
|
|
QTableWidget { background: #282a36; gridline-color: #44475a; }
|
||
|
|
QTableWidget::item:selected { background: #44475a; }
|
||
|
|
QGroupBox { border: 1px solid #44475a; margin-top: 10px; padding-top: 10px; }
|
||
|
|
QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #8be9fd; }
|
||
|
|
QStatusBar { background: #282a36; color: #50fa7b; }
|
||
|
|
QLabel#bigState { font-size: 18pt; font-weight: bold; }
|
||
|
|
QLabel.flag_on { color: #50fa7b; font-weight: bold; }
|
||
|
|
QLabel.flag_off { color: #ff5555; }
|
||
|
|
""")
|
||
|
|
|
||
|
|
# ── Dashboard tab ────────────────────────────────────────────
|
||
|
|
def _build_dashboard(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
|
||
|
|
ctrl_box = QGroupBox("MITM Control")
|
||
|
|
cl = QHBoxLayout(ctrl_box)
|
||
|
|
self.btn_start = QPushButton("▶ START MITM")
|
||
|
|
self.btn_stop = QPushButton("⏹ STOP")
|
||
|
|
self.btn_clear = QPushButton("Clear Log")
|
||
|
|
self.btn_start.clicked.connect(lambda: threading.Thread(target=self.ctrl.start_services, daemon=True).start())
|
||
|
|
self.btn_stop.clicked.connect(lambda: threading.Thread(target=self.ctrl.stop_services, daemon=True).start())
|
||
|
|
self.btn_clear.clicked.connect(self._clear_log)
|
||
|
|
cl.addWidget(self.btn_start)
|
||
|
|
cl.addWidget(self.btn_stop)
|
||
|
|
cl.addWidget(self.btn_clear)
|
||
|
|
cl.addStretch()
|
||
|
|
layout.addWidget(ctrl_box)
|
||
|
|
|
||
|
|
self.state_label = QLabel("MITM: STOPPED")
|
||
|
|
self.state_label.setObjectName("bigState")
|
||
|
|
self.state_label.setStyleSheet("color: #ff5555; font-size: 18pt; font-weight: bold; padding: 10px;")
|
||
|
|
layout.addWidget(self.state_label)
|
||
|
|
|
||
|
|
flags_box = QGroupBox("Services (click to toggle)")
|
||
|
|
self.flags_layout = QVBoxLayout(flags_box)
|
||
|
|
self.svc_buttons = {}
|
||
|
|
for name in ("arp", "dns", "http", "https", "udp10240", "udp20001", "sniffer", "intruder"):
|
||
|
|
btn = QPushButton(f"● {name}: off")
|
||
|
|
btn.setStyleSheet("text-align:left; color:#ff5555; padding:6px; background:#282a36;")
|
||
|
|
btn.clicked.connect(lambda _, n=name: threading.Thread(
|
||
|
|
target=self.ctrl.toggle_service, args=(n,), daemon=True).start())
|
||
|
|
self.svc_buttons[name] = btn
|
||
|
|
self.flags_layout.addWidget(btn)
|
||
|
|
layout.addWidget(flags_box)
|
||
|
|
|
||
|
|
proto_box = QGroupBox("Protocols Seen")
|
||
|
|
self.proto_layout = QVBoxLayout(proto_box)
|
||
|
|
self.proto_label = QLabel("(none yet)")
|
||
|
|
self.proto_label.setStyleSheet("color: #f1fa8c; font-family: monospace;")
|
||
|
|
self.proto_layout.addWidget(self.proto_label)
|
||
|
|
layout.addWidget(proto_box)
|
||
|
|
|
||
|
|
info_box = QGroupBox("Target")
|
||
|
|
il = QFormLayout(info_box)
|
||
|
|
self.lbl_cam = QLabel(self.ctrl.cfg["camera_ip"])
|
||
|
|
self.lbl_us = QLabel(self.ctrl.cfg["our_ip"])
|
||
|
|
self.lbl_rtr = QLabel(self.ctrl.cfg["router_ip"])
|
||
|
|
self.lbl_mac = QLabel(self.ctrl.cfg["camera_mac"])
|
||
|
|
for lbl in (self.lbl_cam, self.lbl_us, self.lbl_rtr, self.lbl_mac):
|
||
|
|
lbl.setStyleSheet("color: #f1fa8c; font-weight: bold;")
|
||
|
|
il.addRow("Camera IP:", self.lbl_cam)
|
||
|
|
il.addRow("Our IP:", self.lbl_us)
|
||
|
|
il.addRow("Router IP:", self.lbl_rtr)
|
||
|
|
il.addRow("Camera MAC:", self.lbl_mac)
|
||
|
|
layout.addWidget(info_box)
|
||
|
|
|
||
|
|
layout.addStretch()
|
||
|
|
return w
|
||
|
|
|
||
|
|
# ── Live log tab ─────────────────────────────────────────────
|
||
|
|
def _build_log_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
bar = QHBoxLayout()
|
||
|
|
bar.addWidget(QLabel("Filter:"))
|
||
|
|
self.log_filter = QLineEdit()
|
||
|
|
self.log_filter.setPlaceholderText("substring filter (live)…")
|
||
|
|
bar.addWidget(self.log_filter)
|
||
|
|
self.autoscroll_cb = QCheckBox("Autoscroll")
|
||
|
|
self.autoscroll_cb.setChecked(True)
|
||
|
|
bar.addWidget(self.autoscroll_cb)
|
||
|
|
btn_clear = QPushButton("Clear")
|
||
|
|
btn_clear.clicked.connect(self._clear_log)
|
||
|
|
bar.addWidget(btn_clear)
|
||
|
|
layout.addLayout(bar)
|
||
|
|
self.log_view = QTextEdit()
|
||
|
|
self.log_view.setReadOnly(True)
|
||
|
|
self.log_view.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
||
|
|
f = QFont("JetBrains Mono", 10)
|
||
|
|
f.setStyleHint(QFont.StyleHint.Monospace)
|
||
|
|
self.log_view.setFont(f)
|
||
|
|
layout.addWidget(self.log_view)
|
||
|
|
return w
|
||
|
|
|
||
|
|
# ── Intruder tab ─────────────────────────────────────────────
|
||
|
|
def _build_intruder_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
head = QHBoxLayout()
|
||
|
|
self.intruder_count = QLabel("0 events")
|
||
|
|
self.intruder_count.setStyleSheet("color: #ff79c6; font-size: 14pt; font-weight: bold;")
|
||
|
|
head.addWidget(self.intruder_count)
|
||
|
|
head.addStretch()
|
||
|
|
btn_clear = QPushButton("Clear")
|
||
|
|
btn_clear.clicked.connect(lambda: (intruder_watch.clear_intruders(), self._refresh_intruders()))
|
||
|
|
head.addWidget(btn_clear)
|
||
|
|
layout.addLayout(head)
|
||
|
|
|
||
|
|
self.intruder_table = QTableWidget(0, 5)
|
||
|
|
self.intruder_table.setHorizontalHeaderLabels(["Time", "Kind", "Source", "Destination", "Detail"])
|
||
|
|
self.intruder_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||
|
|
self.intruder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
|
|
self.intruder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
|
|
layout.addWidget(self.intruder_table)
|
||
|
|
return w
|
||
|
|
|
||
|
|
# ── Cloud API tab ────────────────────────────────────────────
|
||
|
|
def _build_cloud_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
|
||
|
|
cred_box = QGroupBox("UBox Credentials")
|
||
|
|
cl = QFormLayout(cred_box)
|
||
|
|
self.api_email = QLineEdit(self.ctrl.cfg.get("api_email", ""))
|
||
|
|
self.api_pw = QLineEdit(self.ctrl.cfg.get("api_password", ""))
|
||
|
|
self.api_pw.setEchoMode(QLineEdit.EchoMode.Password)
|
||
|
|
cl.addRow("Email:", self.api_email)
|
||
|
|
cl.addRow("Password:", self.api_pw)
|
||
|
|
layout.addWidget(cred_box)
|
||
|
|
|
||
|
|
self.cloud_status = QLabel("not logged in")
|
||
|
|
self.cloud_status.setStyleSheet("color: #ff5555; font-weight: bold; padding: 4px;")
|
||
|
|
layout.addWidget(self.cloud_status)
|
||
|
|
|
||
|
|
btn_box = QHBoxLayout()
|
||
|
|
actions = [
|
||
|
|
("Login", self._cloud_login),
|
||
|
|
("Devices", lambda: self._cloud_call("devices", ubox_client.devices)),
|
||
|
|
("Firmware", lambda: self._cloud_call("firmware", ubox_client.check_firmware)),
|
||
|
|
("Services", lambda: self._cloud_call("services", ubox_client.device_services)),
|
||
|
|
("Families", lambda: self._cloud_call("families", ubox_client.families)),
|
||
|
|
]
|
||
|
|
for label, fn in actions:
|
||
|
|
b = QPushButton(label)
|
||
|
|
b.clicked.connect(fn)
|
||
|
|
btn_box.addWidget(b)
|
||
|
|
layout.addLayout(btn_box)
|
||
|
|
|
||
|
|
fw_box = QGroupBox("Firmware Download")
|
||
|
|
fwl = QHBoxLayout(fw_box)
|
||
|
|
fwl.addWidget(QLabel("host_version:"))
|
||
|
|
self.fw_version = QLineEdit()
|
||
|
|
self.fw_version.setPlaceholderText("blank = auto-try common versions")
|
||
|
|
fwl.addWidget(self.fw_version)
|
||
|
|
fw_btn = QPushButton("Download FW")
|
||
|
|
fw_btn.clicked.connect(lambda: self._cloud_call(
|
||
|
|
"download_fw",
|
||
|
|
lambda cfg: firmware_fetch.check_and_download(
|
||
|
|
cfg, host_version=(self.fw_version.text() or None)
|
||
|
|
),
|
||
|
|
))
|
||
|
|
fwl.addWidget(fw_btn)
|
||
|
|
probe_btn = QPushButton("Probe OTA Bucket")
|
||
|
|
probe_btn.clicked.connect(lambda: self._cloud_call(
|
||
|
|
"ota_probe", ota_bucket_probe.probe
|
||
|
|
))
|
||
|
|
fwl.addWidget(probe_btn)
|
||
|
|
layout.addWidget(fw_box)
|
||
|
|
|
||
|
|
raw_box = QGroupBox("Raw POST")
|
||
|
|
rl = QHBoxLayout(raw_box)
|
||
|
|
self.raw_endpoint = QComboBox()
|
||
|
|
self.raw_endpoint.setEditable(True)
|
||
|
|
self.raw_endpoint.addItem("") # blank first
|
||
|
|
for ep in sorted(set(KNOWN_ENDPOINTS)):
|
||
|
|
self.raw_endpoint.addItem(ep)
|
||
|
|
self.raw_endpoint.lineEdit().setPlaceholderText(
|
||
|
|
f"pick one of {len(KNOWN_ENDPOINTS)} known endpoints, or type your own"
|
||
|
|
)
|
||
|
|
self.raw_endpoint.setMinimumWidth(420)
|
||
|
|
rl.addWidget(self.raw_endpoint, stretch=1)
|
||
|
|
rb = QPushButton("Send")
|
||
|
|
rb.clicked.connect(lambda: self._cloud_call(
|
||
|
|
f"raw {self.raw_endpoint.currentText()}",
|
||
|
|
lambda cfg: ubox_client.raw_request(cfg, self.raw_endpoint.currentText())
|
||
|
|
))
|
||
|
|
rl.addWidget(rb)
|
||
|
|
oam_btn = QPushButton("OAM Send")
|
||
|
|
oam_btn.setToolTip("Sign with OAM HMAC secret and post to oam.ubianet.com")
|
||
|
|
oam_btn.clicked.connect(lambda: self._cloud_call(
|
||
|
|
f"oam {self.raw_endpoint.currentText()}",
|
||
|
|
lambda cfg: ubox_client.oam_post(self.raw_endpoint.currentText(), {})
|
||
|
|
))
|
||
|
|
rl.addWidget(oam_btn)
|
||
|
|
oam_fuzz_btn = QPushButton("OAM Fuzz")
|
||
|
|
oam_fuzz_btn.setToolTip("Probe ~50 candidate OAM admin endpoints")
|
||
|
|
oam_fuzz_btn.clicked.connect(lambda: self._cloud_call(
|
||
|
|
"oam_fuzz",
|
||
|
|
lambda cfg: ubox_client.oam_fuzz(ubox_client.OAM_ENDPOINT_GUESSES)
|
||
|
|
))
|
||
|
|
rl.addWidget(oam_fuzz_btn)
|
||
|
|
layout.addWidget(raw_box)
|
||
|
|
|
||
|
|
resp_box = QGroupBox("Response")
|
||
|
|
rvl = QVBoxLayout(resp_box)
|
||
|
|
self.cloud_response = QPlainTextEdit()
|
||
|
|
self.cloud_response.setReadOnly(True)
|
||
|
|
f = QFont("JetBrains Mono", 10)
|
||
|
|
f.setStyleHint(QFont.StyleHint.Monospace)
|
||
|
|
self.cloud_response.setFont(f)
|
||
|
|
self.cloud_response.setPlaceholderText("API responses will appear here")
|
||
|
|
rvl.addWidget(self.cloud_response)
|
||
|
|
layout.addWidget(resp_box, stretch=1)
|
||
|
|
|
||
|
|
return w
|
||
|
|
|
||
|
|
def _cloud_set_response(self, label, obj):
|
||
|
|
try:
|
||
|
|
text = json.dumps(obj, indent=2, ensure_ascii=False)
|
||
|
|
except Exception:
|
||
|
|
text = repr(obj)
|
||
|
|
self.cloud_response.setPlainText(f"=== {label} ===\n{text}")
|
||
|
|
|
||
|
|
def _cloud_call(self, label, fn):
|
||
|
|
def run():
|
||
|
|
try:
|
||
|
|
result = fn(self.ctrl.cfg)
|
||
|
|
if label == "devices" and isinstance(result, list):
|
||
|
|
self.ctrl._devices = result
|
||
|
|
self.cloud_response_signal.emit(label, result if result is not None else {"result": None})
|
||
|
|
except Exception as e:
|
||
|
|
self.cloud_response_signal.emit(label, {"exception": str(e)})
|
||
|
|
threading.Thread(target=run, daemon=True).start()
|
||
|
|
|
||
|
|
def _cloud_login(self):
|
||
|
|
self.ctrl.cfg["api_email"] = self.api_email.text()
|
||
|
|
self.ctrl.cfg["api_password"] = self.api_pw.text()
|
||
|
|
self.ctrl.cfg.save()
|
||
|
|
def run():
|
||
|
|
ok = ubox_client.login(self.ctrl.cfg)
|
||
|
|
payload = {
|
||
|
|
"logged_in": bool(ok),
|
||
|
|
"token": self.ctrl.cfg.get("api_token", ""),
|
||
|
|
"email": self.ctrl.cfg.get("api_email", ""),
|
||
|
|
}
|
||
|
|
self.cloud_response_signal.emit("login", payload)
|
||
|
|
threading.Thread(target=run, daemon=True).start()
|
||
|
|
|
||
|
|
# ── Fuzzer tab ───────────────────────────────────────────────
|
||
|
|
def _build_fuzzer_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
bb = QHBoxLayout()
|
||
|
|
b1 = QPushButton("Fuzz Endpoints")
|
||
|
|
b1.clicked.connect(lambda: threading.Thread(target=self.ctrl.run_fuzz_endpoints, daemon=True).start())
|
||
|
|
bb.addWidget(b1)
|
||
|
|
b2 = QPushButton("Fuzz Auth")
|
||
|
|
b2.clicked.connect(lambda: threading.Thread(target=self.ctrl.run_fuzz_auth, daemon=True).start())
|
||
|
|
bb.addWidget(b2)
|
||
|
|
bb.addStretch()
|
||
|
|
layout.addLayout(bb)
|
||
|
|
|
||
|
|
param_box = QGroupBox("Param Fuzz")
|
||
|
|
pl = QHBoxLayout(param_box)
|
||
|
|
self.fuzz_ep = QLineEdit()
|
||
|
|
self.fuzz_ep.setPlaceholderText("endpoint name")
|
||
|
|
pl.addWidget(self.fuzz_ep)
|
||
|
|
b3 = QPushButton("Run")
|
||
|
|
b3.clicked.connect(lambda: threading.Thread(
|
||
|
|
target=self.ctrl.run_fuzz_params, args=(self.fuzz_ep.text(),), daemon=True
|
||
|
|
).start())
|
||
|
|
pl.addWidget(b3)
|
||
|
|
layout.addWidget(param_box)
|
||
|
|
|
||
|
|
self.fuzz_status = QLabel("Idle")
|
||
|
|
layout.addWidget(self.fuzz_status)
|
||
|
|
|
||
|
|
b4 = QPushButton("Stop fuzzer")
|
||
|
|
b4.clicked.connect(lambda: (self.ctrl.fuzzer and self.ctrl.fuzzer.stop()))
|
||
|
|
layout.addWidget(b4)
|
||
|
|
layout.addStretch()
|
||
|
|
return w
|
||
|
|
|
||
|
|
# ── Inject tab ───────────────────────────────────────────────
|
||
|
|
def _build_inject_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
|
||
|
|
udp_box = QGroupBox("UDP Inject")
|
||
|
|
ul = QFormLayout(udp_box)
|
||
|
|
self.udp_ip = QLineEdit(self.ctrl.cfg["camera_ip"])
|
||
|
|
self.udp_port = QLineEdit("10240")
|
||
|
|
self.udp_payload = QLineEdit()
|
||
|
|
self.udp_payload.setPlaceholderText("hex payload, e.g. deadbeef")
|
||
|
|
ul.addRow("Dst IP:", self.udp_ip)
|
||
|
|
ul.addRow("Dst port:", self.udp_port)
|
||
|
|
ul.addRow("Payload:", self.udp_payload)
|
||
|
|
b = QPushButton("Send UDP")
|
||
|
|
b.clicked.connect(self._send_udp)
|
||
|
|
ul.addRow("", b)
|
||
|
|
layout.addWidget(udp_box)
|
||
|
|
|
||
|
|
arp_box = QGroupBox("ARP Reply")
|
||
|
|
al = QFormLayout(arp_box)
|
||
|
|
self.arp_src = QLineEdit(self.ctrl.cfg["router_ip"])
|
||
|
|
self.arp_dst = QLineEdit(self.ctrl.cfg["camera_ip"])
|
||
|
|
al.addRow("Src IP (spoof):", self.arp_src)
|
||
|
|
al.addRow("Dst IP:", self.arp_dst)
|
||
|
|
b2 = QPushButton("Send ARP")
|
||
|
|
b2.clicked.connect(lambda: packet.inject(self.ctrl.cfg, {
|
||
|
|
"type": "arp_reply", "src_ip": self.arp_src.text(), "dst_ip": self.arp_dst.text()
|
||
|
|
}))
|
||
|
|
al.addRow("", b2)
|
||
|
|
layout.addWidget(arp_box)
|
||
|
|
|
||
|
|
layout.addStretch()
|
||
|
|
return w
|
||
|
|
|
||
|
|
def _send_udp(self):
|
||
|
|
try:
|
||
|
|
packet.inject(self.ctrl.cfg, {
|
||
|
|
"type": "udp",
|
||
|
|
"dst_ip": self.udp_ip.text(),
|
||
|
|
"dst_port": int(self.udp_port.text()),
|
||
|
|
"payload": self.udp_payload.text(),
|
||
|
|
"payload_hex": True,
|
||
|
|
})
|
||
|
|
except Exception as e:
|
||
|
|
QMessageBox.warning(self, "Inject error", str(e))
|
||
|
|
|
||
|
|
# ── Config tab ───────────────────────────────────────────────
|
||
|
|
def _build_config_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QFormLayout(w)
|
||
|
|
self.cfg_inputs = {}
|
||
|
|
for k, v in self.ctrl.cfg.items():
|
||
|
|
if "password" in k or "token" in k:
|
||
|
|
continue
|
||
|
|
le = QLineEdit(str(v))
|
||
|
|
self.cfg_inputs[k] = le
|
||
|
|
layout.addRow(k, le)
|
||
|
|
b = QPushButton("Save Config")
|
||
|
|
b.clicked.connect(self._save_config)
|
||
|
|
layout.addRow("", b)
|
||
|
|
return w
|
||
|
|
|
||
|
|
# ── CVE tab ──────────────────────────────────────────────────
|
||
|
|
def _build_cve_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
|
||
|
|
head = QHBoxLayout()
|
||
|
|
title = QLabel("CVE verification — original probes, non-destructive")
|
||
|
|
title.setStyleSheet("color:#ff79c6; font-size:13pt; font-weight:bold;")
|
||
|
|
head.addWidget(title)
|
||
|
|
head.addStretch()
|
||
|
|
b_run = QPushButton("▶ Run All Verifications")
|
||
|
|
b_run.clicked.connect(self._cve_run_all)
|
||
|
|
head.addWidget(b_run)
|
||
|
|
b_rep = QPushButton("📝 Generate Report")
|
||
|
|
b_rep.clicked.connect(self._cve_generate_report)
|
||
|
|
head.addWidget(b_rep)
|
||
|
|
layout.addLayout(head)
|
||
|
|
|
||
|
|
# Card per CVE
|
||
|
|
self.cve_cards = {}
|
||
|
|
cve_specs = [
|
||
|
|
("CVE-2025-12636",
|
||
|
|
"Ubia Ubox cloud API leaks IOTC device credentials in plaintext",
|
||
|
|
"The cloud `user/device_list` endpoint returns `cam_user` / `cam_pwd` "
|
||
|
|
"in plaintext to any authenticated owner. These are the IOTC P2P "
|
||
|
|
"device-auth credentials and grant full local control. Verifier calls "
|
||
|
|
"the endpoint and inspects the response.",
|
||
|
|
cve_checks.verify_cve_2025_12636),
|
||
|
|
("CVE-2021-28372",
|
||
|
|
"ThroughTek Kalay P2P UID-based session hijack (UBIC rebrand)",
|
||
|
|
"Kalay master server identifies devices by UID alone. An attacker "
|
||
|
|
"knowing the UID can register the same UID against the master and "
|
||
|
|
"intercept the next legitimate client login. We verify preconditions "
|
||
|
|
"(UID format + camera P2P stack alive) without performing the spoof "
|
||
|
|
"registration.",
|
||
|
|
cve_checks.verify_cve_2021_28372),
|
||
|
|
("CVE-2023-6322 / 6323 / 6324",
|
||
|
|
"ThroughTek Kalay LAN-side memory corruption + auth bypass chain",
|
||
|
|
"Three flaws in the Kalay LAN protocol parser: an auth bypass, a "
|
||
|
|
"heap overflow, and a stack overflow. Verifier sends only safe small "
|
||
|
|
"probes — no overflow payloads — and reports based on stack "
|
||
|
|
"fingerprint and pre/post liveness.",
|
||
|
|
cve_checks.verify_cve_2023_6322_chain),
|
||
|
|
]
|
||
|
|
for cve_id, title_txt, desc, verifier in cve_specs:
|
||
|
|
card = QGroupBox(cve_id)
|
||
|
|
cl = QVBoxLayout(card)
|
||
|
|
t = QLabel(f"<b>{title_txt}</b>")
|
||
|
|
t.setWordWrap(True)
|
||
|
|
t.setStyleSheet("color:#8be9fd;")
|
||
|
|
cl.addWidget(t)
|
||
|
|
d = QLabel(desc)
|
||
|
|
d.setWordWrap(True)
|
||
|
|
d.setStyleSheet("color:#cccccc; padding:4px;")
|
||
|
|
cl.addWidget(d)
|
||
|
|
row = QHBoxLayout()
|
||
|
|
status = QLabel("● not run")
|
||
|
|
status.setStyleSheet("color:#888888; font-weight:bold;")
|
||
|
|
row.addWidget(status)
|
||
|
|
row.addStretch()
|
||
|
|
btn = QPushButton("Verify")
|
||
|
|
btn.clicked.connect(lambda _, v=verifier, cid=cve_id: self._cve_run_one(cid, v))
|
||
|
|
row.addWidget(btn)
|
||
|
|
cl.addLayout(row)
|
||
|
|
evidence = QPlainTextEdit()
|
||
|
|
evidence.setReadOnly(True)
|
||
|
|
evidence.setMaximumHeight(120)
|
||
|
|
evidence.setPlaceholderText("evidence + raw artifacts will appear here")
|
||
|
|
cl.addWidget(evidence)
|
||
|
|
self.cve_cards[cve_id] = {
|
||
|
|
"status": status,
|
||
|
|
"evidence": evidence,
|
||
|
|
"result": None,
|
||
|
|
}
|
||
|
|
layout.addWidget(card)
|
||
|
|
|
||
|
|
layout.addStretch()
|
||
|
|
return w
|
||
|
|
|
||
|
|
def _cve_set_status(self, cve_id, result):
|
||
|
|
card = self.cve_cards.get(cve_id)
|
||
|
|
if not card:
|
||
|
|
return
|
||
|
|
card["result"] = result
|
||
|
|
s = result.get("status", "?")
|
||
|
|
color = {"VULN": "#ff5555", "NOT_VULN": "#50fa7b",
|
||
|
|
"UNKNOWN": "#f1fa8c", "ERROR": "#ff79c6"}.get(s, "#cccccc")
|
||
|
|
card["status"].setText(f"● {s}")
|
||
|
|
card["status"].setStyleSheet(f"color:{color}; font-weight:bold;")
|
||
|
|
text = result.get("evidence", "") + "\n\n" + json.dumps(result.get("details", {}), indent=2, ensure_ascii=False)
|
||
|
|
card["evidence"].setPlainText(text)
|
||
|
|
|
||
|
|
def _cve_run_one(self, cve_id, verifier):
|
||
|
|
def run():
|
||
|
|
try:
|
||
|
|
r = verifier(self.ctrl.cfg)
|
||
|
|
except Exception as e:
|
||
|
|
r = {"cve": cve_id, "status": "ERROR",
|
||
|
|
"title": "exception", "evidence": str(e), "details": {}}
|
||
|
|
self.cve_signal.emit(cve_id, r)
|
||
|
|
threading.Thread(target=run, daemon=True).start()
|
||
|
|
|
||
|
|
def _cve_run_all(self):
|
||
|
|
def run():
|
||
|
|
for cve_id, fn in [
|
||
|
|
("CVE-2025-12636", cve_checks.verify_cve_2025_12636),
|
||
|
|
("CVE-2021-28372", cve_checks.verify_cve_2021_28372),
|
||
|
|
("CVE-2023-6322 / 6323 / 6324", cve_checks.verify_cve_2023_6322_chain),
|
||
|
|
]:
|
||
|
|
try:
|
||
|
|
r = fn(self.ctrl.cfg)
|
||
|
|
except Exception as e:
|
||
|
|
r = {"cve": cve_id, "status": "ERROR",
|
||
|
|
"title": "exception", "evidence": str(e), "details": {}}
|
||
|
|
self.cve_signal.emit(cve_id, r)
|
||
|
|
threading.Thread(target=run, daemon=True).start()
|
||
|
|
|
||
|
|
def _cve_generate_report(self):
|
||
|
|
results = []
|
||
|
|
for cve_id, card in self.cve_cards.items():
|
||
|
|
if card["result"]:
|
||
|
|
results.append(card["result"])
|
||
|
|
if not results:
|
||
|
|
QMessageBox.information(self, "Report", "Run at least one verification first.")
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
path = cve_checks.build_report(self.ctrl.cfg, results)
|
||
|
|
QMessageBox.information(self, "Report written", f"Saved to:\n{path}")
|
||
|
|
except Exception as e:
|
||
|
|
QMessageBox.warning(self, "Report failed", str(e))
|
||
|
|
|
||
|
|
# ── Help tab ─────────────────────────────────────────────────
|
||
|
|
def _build_help_tab(self):
|
||
|
|
w = QWidget()
|
||
|
|
layout = QVBoxLayout(w)
|
||
|
|
view = QTextEdit()
|
||
|
|
view.setReadOnly(True)
|
||
|
|
view.setHtml("""
|
||
|
|
<h2 style="color:#50fa7b">SetecSuite — Camera MITM</h2>
|
||
|
|
<p><b>Target:</b> Javiscam/UBox cam (TUTK Kalay) at the IP shown in the Dashboard.</p>
|
||
|
|
|
||
|
|
<h3 style="color:#8be9fd">Tabs</h3>
|
||
|
|
<ul>
|
||
|
|
<li><b>Dashboard</b> — START/STOP MITM, click any service row to toggle individually, watch protocol counts and target info.</li>
|
||
|
|
<li><b>Live Log</b> — every log line, color-coded. Filter by substring. Toggle Autoscroll if you want to read history while traffic flows.</li>
|
||
|
|
<li><b>Intruders</b> — table of detected suspicious activity (ARP spoofs, unknown LAN peers, unexpected outbound dests).</li>
|
||
|
|
<li><b>Cloud API</b> — UBox portal: Login, Devices, Firmware, Services, Families, Raw POST. Firmware Download tries multiple host_versions to trick the cloud into returning an OTA URL.</li>
|
||
|
|
<li><b>Fuzzer</b> — endpoint discovery (~146 known + ~600 wordlist), parameter mutation, auth bypass.</li>
|
||
|
|
<li><b>Inject</b> — craft and send raw UDP, ARP, or DNS packets.</li>
|
||
|
|
<li><b>Config</b> — edit any config key, save to disk.</li>
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
<h3 style="color:#8be9fd">Services (Dashboard buttons)</h3>
|
||
|
|
<ul>
|
||
|
|
<li><b>arp</b> — ARP spoof: tell camera we are the gateway, tell gateway we are the camera.</li>
|
||
|
|
<li><b>dns</b> — DNS spoof: redirect cloud lookups to us.</li>
|
||
|
|
<li><b>http / https</b> — Intercept ports 80/443. HTTPS uses our regen'd cert with SAN list for ubianet, aliyuncs, myqcloud.</li>
|
||
|
|
<li><b>udp10240</b> — IOTC P2P relay port (Tencent/Alibaba clouds use this).</li>
|
||
|
|
<li><b>udp20001</b> — Push notification service.</li>
|
||
|
|
<li><b>sniffer</b> — Raw packet sniffer; logs cam:src → us:dst with conntrack-extracted original destination + protocol fingerprint.</li>
|
||
|
|
<li><b>intruder</b> — Detects: ARP spoofs against the camera, unknown LAN hosts contacting it, unexpected outbound destinations not in known cloud whitelist.</li>
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
<h3 style="color:#8be9fd">Workflow: get firmware</h3>
|
||
|
|
<ol>
|
||
|
|
<li>Cloud API → fill creds → Login</li>
|
||
|
|
<li>Devices (populates device_uid)</li>
|
||
|
|
<li>Download FW (auto-tries 6 versions, or type one in the field)</li>
|
||
|
|
<li>If empty: start MITM, power-cycle camera, watch Live Log for the camera's own check_version request</li>
|
||
|
|
</ol>
|
||
|
|
|
||
|
|
<h3 style="color:#8be9fd">REST API</h3>
|
||
|
|
<p>Always running on <code>http://127.0.0.1:9090</code>. Endpoints: <code>/status, /logs, /devices, /config, /fuzz/results, /start, /stop, /command, /api, /fuzz/endpoints, /fuzz/params, /fuzz/auth, /inject</code></p>
|
||
|
|
|
||
|
|
<h3 style="color:#8be9fd">Files</h3>
|
||
|
|
<ul>
|
||
|
|
<li>Config: <code>~/setec_suite/cam-mitm/config.json</code></li>
|
||
|
|
<li>Logs: <code>/root/dumps/mitm_logs/mitm.log</code> (rotates at 1 GiB)</li>
|
||
|
|
<li>Captures: <code>/root/dumps/mitm_logs/raw_*.bin</code></li>
|
||
|
|
<li>SSL cert: regen with <code>sudo /home/snake/setec_suite/cam-mitm/regen_cert.sh</code></li>
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
<h3 style="color:#8be9fd">Known credentials (from APK)</h3>
|
||
|
|
<ul>
|
||
|
|
<li><b>Camera local:</b> admin / yyc1G::HPEv7om3O</li>
|
||
|
|
<li><b>Camera local alt:</b> admin / iotCam31</li>
|
||
|
|
<li><b>Box-mode:</b> admin / admin</li>
|
||
|
|
<li><b>OAM HMAC secret:</b> 2894df25f8f740dff5266bc155c662ca</li>
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
<h3 style="color:#8be9fd">Run</h3>
|
||
|
|
<p><code>sudo /usr/bin/python3 /home/snake/setec_suite/cam-mitm/gui.py</code></p>
|
||
|
|
<p>(Custom Python 3.14 build at /usr/local/bin lacks _curses and PyQt6 — must use the system /usr/bin/python3)</p>
|
||
|
|
""")
|
||
|
|
layout.addWidget(view)
|
||
|
|
return w
|
||
|
|
|
||
|
|
def _save_config(self):
|
||
|
|
for k, le in self.cfg_inputs.items():
|
||
|
|
old = self.ctrl.cfg[k]
|
||
|
|
v = le.text()
|
||
|
|
try:
|
||
|
|
if isinstance(old, int):
|
||
|
|
v = int(v)
|
||
|
|
elif isinstance(old, float):
|
||
|
|
v = float(v)
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
self.ctrl.cfg[k] = v
|
||
|
|
self.ctrl.cfg.save()
|
||
|
|
log("Config saved from GUI", C_SUCCESS)
|
||
|
|
|
||
|
|
# ── Periodic refresh ─────────────────────────────────────────
|
||
|
|
def _tick(self):
|
||
|
|
# New log lines
|
||
|
|
with lock:
|
||
|
|
total = len(log_lines)
|
||
|
|
if total < self._last_log_idx:
|
||
|
|
self._last_log_idx = 0 # deque rolled
|
||
|
|
new = list(log_lines)[self._last_log_idx:]
|
||
|
|
self._last_log_idx = total
|
||
|
|
if new:
|
||
|
|
self.bridge.new_lines.emit(new)
|
||
|
|
|
||
|
|
# State
|
||
|
|
if self.ctrl.services_running:
|
||
|
|
self.state_label.setText("MITM: RUNNING")
|
||
|
|
self.state_label.setStyleSheet("color: #50fa7b; font-size: 18pt; font-weight: bold; padding: 10px;")
|
||
|
|
else:
|
||
|
|
self.state_label.setText("MITM: STOPPED")
|
||
|
|
self.state_label.setStyleSheet("color: #ff5555; font-size: 18pt; font-weight: bold; padding: 10px;")
|
||
|
|
|
||
|
|
for name, btn in self.svc_buttons.items():
|
||
|
|
on = self.ctrl.flags.get(name, False)
|
||
|
|
btn.setText(f"● {name}: {'ON' if on else 'off'}")
|
||
|
|
color = "#50fa7b" if on else "#ff5555"
|
||
|
|
btn.setStyleSheet(f"text-align:left; color:{color}; padding:6px; background:#282a36;")
|
||
|
|
|
||
|
|
# Protocols seen
|
||
|
|
counts = proto_id.seen_counts()
|
||
|
|
if counts:
|
||
|
|
txt = " ".join(f"{k}={v}" for k, v in sorted(counts.items(), key=lambda x: -x[1]))
|
||
|
|
self.proto_label.setText(txt)
|
||
|
|
|
||
|
|
# Intruders
|
||
|
|
self._refresh_intruders()
|
||
|
|
|
||
|
|
# Cloud status
|
||
|
|
if self.ctrl.cfg.get("api_token"):
|
||
|
|
tok = self.ctrl.cfg["api_token"][:16]
|
||
|
|
self.cloud_status.setText(f"logged in as {self.ctrl.cfg.get('api_email','?')} token={tok}…")
|
||
|
|
self.cloud_status.setStyleSheet("color: #50fa7b; font-weight: bold; padding: 4px;")
|
||
|
|
else:
|
||
|
|
self.cloud_status.setText("not logged in")
|
||
|
|
self.cloud_status.setStyleSheet("color: #ff5555; font-weight: bold; padding: 4px;")
|
||
|
|
|
||
|
|
# Status bar
|
||
|
|
n_devs = len(self.ctrl._devices) if self.ctrl._devices else 0
|
||
|
|
self.status.showMessage(
|
||
|
|
f"REST :{self.ctrl.cfg['rest_port']} | Devices: {n_devs} | "
|
||
|
|
f"Token: {'yes' if self.ctrl.cfg['api_token'] else 'no'} | "
|
||
|
|
f"Intruders: {len(intruder_watch.get_intruders())}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _refresh_intruders(self):
|
||
|
|
items = intruder_watch.get_intruders()
|
||
|
|
self.intruder_count.setText(f"{len(items)} events")
|
||
|
|
if self.intruder_table.rowCount() != len(items):
|
||
|
|
self.intruder_table.setRowCount(len(items))
|
||
|
|
for i, e in enumerate(items):
|
||
|
|
self.intruder_table.setItem(i, 0, QTableWidgetItem(e["ts"]))
|
||
|
|
kind_item = QTableWidgetItem(e["kind"])
|
||
|
|
kind_item.setForeground(QColor("#ff79c6"))
|
||
|
|
self.intruder_table.setItem(i, 1, kind_item)
|
||
|
|
self.intruder_table.setItem(i, 2, QTableWidgetItem(e["src"]))
|
||
|
|
self.intruder_table.setItem(i, 3, QTableWidgetItem(e["dst"]))
|
||
|
|
self.intruder_table.setItem(i, 4, QTableWidgetItem(e["detail"]))
|
||
|
|
|
||
|
|
# ── Log append ───────────────────────────────────────────────
|
||
|
|
def _append_log(self, lines):
|
||
|
|
flt = self.log_filter.text().lower()
|
||
|
|
autoscroll = self.autoscroll_cb.isChecked()
|
||
|
|
|
||
|
|
# Preserve current scroll position when autoscroll off
|
||
|
|
sb = self.log_view.verticalScrollBar()
|
||
|
|
old_pos = sb.value()
|
||
|
|
old_user_cursor = self.log_view.textCursor()
|
||
|
|
|
||
|
|
write_cursor = QTextCursor(self.log_view.document())
|
||
|
|
write_cursor.movePosition(QTextCursor.MoveOperation.End)
|
||
|
|
for line, color in lines:
|
||
|
|
if flt and flt not in line.lower():
|
||
|
|
continue
|
||
|
|
fmt = write_cursor.charFormat()
|
||
|
|
fmt.setForeground(QT_COLORS.get(color, QT_COLORS[C_NONE]))
|
||
|
|
write_cursor.setCharFormat(fmt)
|
||
|
|
write_cursor.insertText(line + "\n")
|
||
|
|
|
||
|
|
if autoscroll:
|
||
|
|
self.log_view.moveCursor(QTextCursor.MoveOperation.End)
|
||
|
|
self.log_view.ensureCursorVisible()
|
||
|
|
else:
|
||
|
|
self.log_view.setTextCursor(old_user_cursor)
|
||
|
|
sb.setValue(old_pos)
|
||
|
|
|
||
|
|
def _clear_log(self):
|
||
|
|
self.log_view.clear()
|
||
|
|
with lock:
|
||
|
|
log_lines.clear()
|
||
|
|
self._last_log_idx = 0
|
||
|
|
|
||
|
|
def closeEvent(self, ev):
|
||
|
|
if self.ctrl.services_running:
|
||
|
|
self.ctrl.stop_services()
|
||
|
|
self.ctrl.running = False
|
||
|
|
close_logfile()
|
||
|
|
ev.accept()
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
if os.geteuid() != 0:
|
||
|
|
print("Run with: sudo /usr/bin/python3 gui.py")
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
ctrl = Controller()
|
||
|
|
os.makedirs(ctrl.cfg["log_dir"], exist_ok=True)
|
||
|
|
init_logfile(f"{ctrl.cfg['log_dir']}/mitm.log")
|
||
|
|
|
||
|
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||
|
|
|
||
|
|
app = QApplication(sys.argv)
|
||
|
|
win = MainWindow(ctrl)
|
||
|
|
win.show()
|
||
|
|
rc = app.exec()
|
||
|
|
if ctrl.services_running:
|
||
|
|
ctrl.stop_services()
|
||
|
|
close_logfile()
|
||
|
|
sys.exit(rc)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|