feat(factory): add local flashing toolkit and secure OTA defaults
This commit is contained in:
246
tools/esptool-factory/factory_private_config.py
Normal file
246
tools/esptool-factory/factory_private_config.py
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||
|
||||
from factory_common.signing import SignatureError, verify_file_signature
|
||||
|
||||
|
||||
DEFAULT_CONFIG_NAME = "factory_private.json"
|
||||
CONF_DIR_NAME = "conf"
|
||||
CONFIG_ENV_KEYS: Tuple[str, ...] = ("FACTORY_PRIVATE_CONFIG", "FACTORY_PRIVATE_CONFIG_PATH")
|
||||
CACHE_DIR_NAME = "talkingq-factory"
|
||||
CACHE_FILENAME = "factory_private_path.txt"
|
||||
_MISSING = object()
|
||||
_ENC_MAGIC = b"FENC"
|
||||
|
||||
|
||||
class EncryptedConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_private_config_path(base_dir: Path, allow_plain: bool = True) -> Optional[Path]:
|
||||
for key in CONFIG_ENV_KEYS:
|
||||
value = os.getenv(key)
|
||||
if value:
|
||||
return Path(value).expanduser()
|
||||
candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME
|
||||
enc_candidate = Path(str(candidate) + ".enc")
|
||||
if allow_plain and candidate.exists():
|
||||
return candidate
|
||||
if enc_candidate.exists():
|
||||
return enc_candidate
|
||||
return None
|
||||
|
||||
|
||||
def resolve_private_config_source_path(base_dir: Path) -> Optional[Path]:
|
||||
for key in CONFIG_ENV_KEYS:
|
||||
value = os.getenv(key)
|
||||
if value:
|
||||
path = Path(value).expanduser()
|
||||
if path.suffix == ".enc":
|
||||
plain = Path(str(path)[:-4])
|
||||
if plain.exists():
|
||||
return plain
|
||||
return path
|
||||
candidate = base_dir / CONF_DIR_NAME / DEFAULT_CONFIG_NAME
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
enc_candidate = Path(str(candidate) + ".enc")
|
||||
if enc_candidate.exists():
|
||||
return enc_candidate
|
||||
return None
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
if getattr(sys, "frozen", False):
|
||||
return True
|
||||
if getattr(sys, "nuitka_version", None):
|
||||
return True
|
||||
if globals().get("__compiled__") is not None:
|
||||
return True
|
||||
return "__compiled__" in sys.modules
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
if os.name == "nt":
|
||||
base = os.getenv("APPDATA") or os.getenv("LOCALAPPDATA")
|
||||
if base:
|
||||
return Path(base) / CACHE_DIR_NAME
|
||||
return Path.home() / "AppData" / "Roaming" / CACHE_DIR_NAME
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Application Support" / CACHE_DIR_NAME
|
||||
base = os.getenv("XDG_STATE_HOME") or os.getenv("XDG_CONFIG_HOME")
|
||||
if base:
|
||||
return Path(base) / CACHE_DIR_NAME
|
||||
return Path.home() / ".config" / CACHE_DIR_NAME
|
||||
|
||||
|
||||
def _cache_path() -> Path:
|
||||
return _cache_dir() / CACHE_FILENAME
|
||||
|
||||
|
||||
def _load_cached_path() -> Optional[Path]:
|
||||
path = _cache_path()
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
return Path(raw).expanduser()
|
||||
|
||||
|
||||
def _save_cached_path(path: Path) -> None:
|
||||
cache_dir = _cache_dir()
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
_cache_path().write_text(str(path), encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def _prompt_private_config_path(title: str, initial_dir: Optional[Path] = None) -> Optional[Path]:
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
except Exception:
|
||||
return None
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
dialog_args = {
|
||||
"title": title,
|
||||
"filetypes": [("JSON", "*.json"), ("All", "*.*")],
|
||||
}
|
||||
if initial_dir:
|
||||
dialog_args["initialdir"] = str(initial_dir)
|
||||
path = filedialog.askopenfilename(**dialog_args)
|
||||
root.destroy()
|
||||
if not path:
|
||||
return None
|
||||
return Path(path).expanduser()
|
||||
|
||||
|
||||
def ensure_private_config_path(base_dir: Path, prompt_title: str = "选择工厂私有配置文件") -> Path:
|
||||
path = resolve_private_config_path(base_dir)
|
||||
if path is not None and path.exists():
|
||||
_save_cached_path(path)
|
||||
return path
|
||||
cached = _load_cached_path()
|
||||
if cached is not None and cached.exists():
|
||||
os.environ["FACTORY_PRIVATE_CONFIG"] = str(cached)
|
||||
return cached
|
||||
initial_dir = cached.parent if cached else None
|
||||
selected = _prompt_private_config_path(prompt_title, initial_dir=initial_dir)
|
||||
if selected is None:
|
||||
raise FileNotFoundError(
|
||||
"缺少工厂私有配置文件。请设置环境变量 "
|
||||
f"{'/'.join(CONFIG_ENV_KEYS)} 或在弹窗中选择配置文件。"
|
||||
)
|
||||
os.environ["FACTORY_PRIVATE_CONFIG"] = str(selected)
|
||||
_save_cached_path(selected)
|
||||
return selected
|
||||
|
||||
|
||||
def _is_encrypted_payload(payload: bytes) -> bool:
|
||||
return payload.startswith(_ENC_MAGIC)
|
||||
|
||||
|
||||
def _load_private_config_from_path(
|
||||
path: Path,
|
||||
verify_signature: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
if verify_signature:
|
||||
try:
|
||||
verify_file_signature(path)
|
||||
except SignatureError as exc:
|
||||
raise PermissionError(f"私有配置签名校验失败:{exc}") from exc
|
||||
try:
|
||||
payload = path.read_bytes()
|
||||
except OSError as exc: # noqa: BLE001
|
||||
raise FileNotFoundError(f"无法读取私有配置文件:{path}") from exc
|
||||
if _is_encrypted_payload(payload):
|
||||
raise EncryptedConfigError("私有配置已加密。当前版本仅支持明文配置文件。")
|
||||
else:
|
||||
try:
|
||||
raw = payload.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f"私有配置文件格式错误:{path}") from exc
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"私有配置文件格式错误:{path}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("私有配置文件根节点必须是对象。")
|
||||
return data
|
||||
|
||||
|
||||
def load_private_config(base_dir: Path) -> Dict[str, Any]:
|
||||
path = resolve_private_config_path(base_dir, allow_plain=True)
|
||||
if path is None or not path.exists():
|
||||
raise FileNotFoundError(
|
||||
"缺少工厂私有配置文件。请设置环境变量 "
|
||||
f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。"
|
||||
)
|
||||
return _load_private_config_from_path(path)
|
||||
|
||||
|
||||
def load_private_config_from_path(path: Path) -> Dict[str, Any]:
|
||||
return _load_private_config_from_path(path)
|
||||
|
||||
|
||||
def load_private_config_source(base_dir: Path) -> Dict[str, Any]:
|
||||
path = resolve_private_config_source_path(base_dir)
|
||||
if path is None or not path.exists():
|
||||
raise FileNotFoundError(
|
||||
"缺少工厂私有配置文件。请设置环境变量 "
|
||||
f"{'/'.join(CONFIG_ENV_KEYS)} 指定配置路径。"
|
||||
)
|
||||
if path.suffix == ".enc":
|
||||
raise EncryptedConfigError(
|
||||
"私有配置源需要明文 factory_private.json(打包用)。请提供明文配置文件。"
|
||||
)
|
||||
return _load_private_config_from_path(
|
||||
path,
|
||||
verify_signature=False,
|
||||
)
|
||||
|
||||
|
||||
def _walk(data: Dict[str, Any], keys: Iterable[str]) -> Any:
|
||||
current: Any = data
|
||||
for key in keys:
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
return _MISSING
|
||||
current = current[key]
|
||||
return current
|
||||
|
||||
|
||||
def get_required(
|
||||
data: Dict[str, Any],
|
||||
keys: Iterable[str],
|
||||
expected_type: Optional[tuple[type, ...]] = None,
|
||||
allow_empty: bool = False,
|
||||
) -> Any:
|
||||
value = _walk(data, keys)
|
||||
if value is _MISSING:
|
||||
raise KeyError("缺少私有配置项: " + ".".join(keys))
|
||||
if expected_type and not isinstance(value, expected_type):
|
||||
raise TypeError("私有配置类型错误: " + ".".join(keys))
|
||||
if not allow_empty and isinstance(value, str) and not value.strip():
|
||||
raise ValueError("私有配置不能为空: " + ".".join(keys))
|
||||
return value
|
||||
|
||||
|
||||
def get_optional(data: Dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
|
||||
value = _walk(data, keys)
|
||||
if value is _MISSING:
|
||||
return default
|
||||
return value
|
||||
Reference in New Issue
Block a user