"""Monitor 启动配置 — 所有可配置项均来自 config.json,后台「启动配置」页可编辑。""" import json import os from urllib.parse import quote from framework.settings import BASE_DIR from app.utils.Secrets import get_runtime_secret def _resolve_path(path, base=None): """相对路径基于项目根目录 BASE_DIR 解析为绝对路径。""" base = base or str(BASE_DIR) if not path: return "" p = str(path).strip() if not p: return "" if os.path.isabs(p): return os.path.normpath(p) return os.path.normpath(os.path.join(base, p.replace("\\", "/"))) def _bool(v, default=False): if v is None: return default if isinstance(v, bool): return v if isinstance(v, (int, float)): return v != 0 return str(v).strip().lower() in ("1", "true", "yes", "on") def _int(v, default=0): try: return int(v) except (TypeError, ValueError): return default def _float(v, default=0.0): try: return float(v) except (TypeError, ValueError): return default class Config: def __init__(self, filepath): self.__filepath = filepath config_data = None for encoding in ["utf-8", "gbk"]: try: with open(filepath, "r", encoding=encoding) as f: config_data = json.loads(f.read()) break except Exception as e: print("Config.__init__() error:%s,encoding=%s|%s" % (str(e), encoding, str(filepath))) if not config_data: raise Exception("Config.__init__() read %s error" % str(filepath)) self.__config_data = config_data self._apply(config_data) def _apply(self, config_data): self.__config_data_str = str(config_data) base = str(BASE_DIR) # —— 基础 —— # Internal credentials are never sourced from the web-editable config. self.internalApiSecret = get_runtime_secret("internal_api_secret") self.internalHost = "127.0.0.1" self.externalHost = config_data.get("host", "127.0.0.1") self.adminPort = _int(config_data.get("adminPort"), 10001) self.logDebug = _bool(config_data.get("logDebug"), False) self.isEnableLoginCaptcha = _bool(config_data.get("isEnableLoginCaptcha"), True) self.isEnableUpdatePopup = _bool(config_data.get("isEnableUpdatePopup"), False) # Both flags require an explicit local opt-in. Endpoints are environment-only. self.telemetryEnabled = _bool(config_data.get("telemetryEnabled"), False) self.updateCheckEnabled = _bool(config_data.get("updateCheckEnabled"), False) self.autoAddStreamProxy = _bool(config_data.get("autoAddStreamProxy"), True) self.autoAddStreamProxySleep = _int(config_data.get("autoAddStreamProxySleep"), 15) # —— 路径与工具 —— self.ffmpeg = str(os.environ.get("MONITOR_FFMPEG") or config_data.get("ffmpeg", "ffmpeg") or "ffmpeg").strip() self.fontPath = _resolve_path(config_data.get("fontPath", ""), base) self.uploadDir = _resolve_path(config_data.get("uploadDir", "static/upload"), base) self.storageDir = _resolve_path(config_data.get("storageDir", "static/storage"), base) # —— 流媒体 ZLM —— self.mediaHttpPort = _int(config_data.get("mediaHttpPort"), 10002) self.mediaRtspPort = _int(config_data.get("mediaRtspPort"), 10554) self.mediaRtmpPort = _int(config_data.get("mediaRtmpPort"), 10935) self.isEnableMediaProxyRtmp = _bool(config_data.get("isEnableMediaProxyRtmp"), False) self.mediaSecret = get_runtime_secret("media_secret") self.mediaStartPath = _resolve_path(config_data.get("mediaStartPath", ""), base) self.mediaStartConfigPath = _resolve_path(config_data.get("mediaStartConfigPath", ""), base) self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False) self.adminHost = "http://" + self.internalHost + ":" + str(self.adminPort) self.mediaHttpHost = "http://" + self.internalHost + ":" + str(self.mediaHttpPort) # —— 视频分析 —— self.analysisTargetFps = _int(config_data.get("analysisTargetFps"), 5) self.analysisConfThreshold = _float(config_data.get("analysisConfThreshold"), 0.4) self.analysisProcessMode = _int(config_data.get("analysisProcessMode"), 1) self.analysisSharedInference = _bool(config_data.get("analysisSharedInference"), True) self.analysisInferenceWorkers = max(1, _int(config_data.get("analysisInferenceWorkers"), 2)) # —— 录像 —— self.recordingEnabled = _bool(config_data.get("recordingEnabled"), False) self.recordingSegmentSeconds = max(60, _int(config_data.get("recordingSegmentSeconds"), 600)) self.recordingRetainDays = max(0, _int(config_data.get("recordingRetainDays"), 7)) self.recordingRetainGb = max(0.0, _float(config_data.get("recordingRetainGb"), 0.0)) # —— GB28181 SIP —— __sip = config_data.get("sipServer") or {} self.sipServer = { "sipServerIp": str(__sip.get("sipServerIp", "127.0.0.1")).strip(), "sipServerPort": _int(__sip.get("sipServerPort"), 15060), "sipServerNonce": get_runtime_secret("sip_server_nonce"), "sipTransferMode": _int(__sip.get("sipTransferMode"), 0), "sipServerId": str(__sip.get("sipServerId", "34020000002000000001")).strip(), "sipServerRealm": str(__sip.get("sipServerRealm", "3402000000")).strip(), "sipServerPass": get_runtime_secret("sip_server_password"), "sipServerTimeout": _int(__sip.get("sipServerTimeout"), 1800), "sipServerExpiry": _int(__sip.get("sipServerExpiry"), 3600), "rtpTransferMode": _int(__sip.get("rtpTransferMode"), 0), "rtpTransferAudioType": _int(__sip.get("rtpTransferAudioType"), 0), "autoInviteAfterRecCateLog": _bool(__sip.get("autoInviteAfterRecCateLog"), True), } self._ensure_storage_dirs() self._sync_media_server_config() def _sync_media_server_config(self): """Inject runtime-only secrets into the selected ZLM config. ZLM supports a static hook URL but cannot produce our per-request HMAC, so its two hooks carry the high-entropy token and are additionally restricted to a direct loopback peer by Django middleware. """ source_path = self.mediaStartConfigPath if not source_path or not os.path.isfile(source_path): return try: lines = None source_encoding = "utf-8" for encoding in ("utf-8", "gbk"): try: with open(source_path, "r", encoding=encoding) as f: lines = f.readlines() source_encoding = encoding break except UnicodeDecodeError: continue if lines is None: raise RuntimeError("unsupported config encoding") section = "" token = quote(self.internalApiSecret, safe="") hook_urls = { "on_publish": "http://127.0.0.1:%d/inner/on_publish?token=%s" % (self.adminPort, token), "on_stream_not_found": "http://127.0.0.1:%d/inner/on_stream_not_found?token=%s" % (self.adminPort, token), } output = [] for line in lines: stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): section = stripped[1:-1].strip().lower() if "=" in line and not stripped.startswith(";"): key = line.split("=", 1)[0].strip() if section == "api" and key == "secret": newline = "secret=%s\n" % self.mediaSecret line = newline elif section == "hook" and key in hook_urls: newline = "%s=%s\n" % (key, hook_urls[key]) line = newline output.append(line) runtime_dir = os.path.join(str(BASE_DIR), ".runtime") os.makedirs(runtime_dir, exist_ok=True) runtime_path = os.path.join(runtime_dir, "zlm-config.ini") temp = runtime_path + ".tmp" with open(temp, "w", encoding=source_encoding, newline="") as f: f.writelines(output) os.replace(temp, runtime_path) self.mediaStartConfigPath = runtime_path except Exception as e: raise RuntimeError("failed to secure ZLM config %s: %s" % (source_path, e)) def _ensure_storage_dirs(self): for d in (self.uploadDir, self.storageDir): if d and not os.path.exists(d): os.makedirs(d, exist_ok=True) self.uploadAlgorithmWeightDir = os.path.join(self.uploadDir, "weight") self.uploadAudioDir = os.path.join(self.uploadDir, "audio") self.uploadAudioDir_www = "/upload/audio/" self.storageTempDir = os.path.join(self.storageDir, "temp") self.storageAlarmDir = os.path.join(self.storageDir, "alarm") self.storageSnapshotsDir = os.path.join(self.storageDir, "snapshots") self.storageRecordDir = os.path.join(self.storageDir, "record") for d in (self.storageTempDir, self.storageAlarmDir, self.storageSnapshotsDir, self.storageRecordDir): if not os.path.exists(d): os.makedirs(d, exist_ok=True) self.storageDir_www = "/storage/openAccess?filename=" def getStr(self): return json.dumps(self.to_dict(), ensure_ascii=False) def to_dict(self, include_secrets=False): """供启动配置页 / API 使用的完整配置快照。""" d = dict(self.__config_data) d.setdefault("sipServer", {}) d.pop("safe", None) d.pop("internalApiSecret", None) d.pop("mediaSecret", None) # 布尔/int 与内存态保持一致 d["host"] = self.externalHost d["autoAddStreamProxy"] = self.autoAddStreamProxy d["autoAddStreamProxySleep"] = self.autoAddStreamProxySleep d["isEnableLoginCaptcha"] = self.isEnableLoginCaptcha d["logDebug"] = self.logDebug d["isEnableUpdatePopup"] = self.isEnableUpdatePopup d["telemetryEnabled"] = self.telemetryEnabled d["updateCheckEnabled"] = self.updateCheckEnabled d["isEnableMediaProxyRtmp"] = self.isEnableMediaProxyRtmp d["autoStartMedia"] = self.autoStartMedia d["analysisSharedInference"] = self.analysisSharedInference d["recordingEnabled"] = self.recordingEnabled d["sipServer"] = dict(self.sipServer) d["sipServer"].pop("sipServerPass", None) d["sipServer"].pop("sipServerNonce", None) if include_secrets: d["internalApiSecret"] = self.internalApiSecret d["mediaSecret"] = self.mediaSecret d["sipServer"]["sipServerPass"] = self.sipServer["sipServerPass"] d["sipServer"]["sipServerNonce"] = self.sipServer["sipServerNonce"] return d def save_from_web(self, params): """合并 Web 表单并写回 config.json(保留未在表单中的键)。""" data = dict(self.__config_data) p = params or {} def _set(key, val): data[key] = val for key in ("ffmpeg",): if key in p: _set(key, str(p.get(key) or "").strip()) if "host" in p: _set("host", str(p.get("host") or "").strip()) if "uploadDir" in p: _set("uploadDir", str(p.get("uploadDir") or "").strip()) if "storageDir" in p: _set("storageDir", str(p.get("storageDir") or "").strip()) if "fontPath" in p: _set("fontPath", str(p.get("fontPath") or "").strip()) if "mediaStartPath" in p: _set("mediaStartPath", str(p.get("mediaStartPath") or "").strip()) if "mediaStartConfigPath" in p: _set("mediaStartConfigPath", str(p.get("mediaStartConfigPath") or "").strip()) int_keys = ( "adminPort", "mediaHttpPort", "mediaRtspPort", "mediaRtmpPort", "autoAddStreamProxySleep", "analysisTargetFps", "analysisProcessMode", "analysisInferenceWorkers", "recordingSegmentSeconds", "recordingRetainDays", ) for key in int_keys: if key in p: _set(key, _int(p.get(key), data.get(key))) float_keys = ("analysisConfThreshold", "recordingRetainGb") for key in float_keys: if key in p: _set(key, _float(p.get(key), data.get(key))) bool_keys = ( "autoAddStreamProxy", "isEnableLoginCaptcha", "logDebug", "isEnableUpdatePopup", "telemetryEnabled", "updateCheckEnabled", "isEnableMediaProxyRtmp", "autoStartMedia", "analysisSharedInference", "recordingEnabled", ) for key in bool_keys: if key in p: _set(key, _bool(p.get(key), _bool(data.get(key)))) sip = dict(data.get("sipServer") or {}) sip_map = { "sipServerIp": "sipServerIp", "sipServerPort": ("sipServerPort", _int), "sipTransferMode": ("sipTransferMode", _int), "sipServerId": "sipServerId", "sipServerRealm": "sipServerRealm", "sipServerTimeout": ("sipServerTimeout", _int), "sipServerExpiry": ("sipServerExpiry", _int), "rtpTransferMode": ("rtpTransferMode", _int), "rtpTransferAudioType": ("rtpTransferAudioType", _int), "autoInviteAfterRecCateLog": ("autoInviteAfterRecCateLog", _bool), } for param_key, spec in sip_map.items(): if param_key not in p: continue if isinstance(spec, tuple): sip_key, conv = spec sip[sip_key] = conv(p.get(param_key), sip.get(sip_key)) else: sip[spec] = str(p.get(param_key) or "").strip() data["sipServer"] = sip for _k in ( "install", "code", "name", "describe", "safe", "internalApiSecret", "mediaSecret", ): data.pop(_k, None) for _k in ("sipServerPass", "sipServerNonce"): data["sipServer"].pop(_k, None) with open(self.__filepath, "w", encoding="utf-8") as f: json.dump(data, f, indent=4, ensure_ascii=False) self.__config_data = data self._apply(data)