初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
FRP_CATALOG_SCHEMA_VERSION = 1
|
||||
MAX_FRP_CONFIG_BYTES = 1024 * 1024
|
||||
SUPPORTED_FRP_SUFFIXES = {".toml", ".yaml", ".yml", ".json", ".ini"}
|
||||
_SAFE_NAME_RE = re.compile(r"[^\w\-. ()\u4e00-\u9fff]+", re.UNICODE)
|
||||
|
||||
|
||||
class FrpError(RuntimeError):
|
||||
"""Base error whose text is safe to return to the browser."""
|
||||
|
||||
|
||||
class FrpValidationError(FrpError):
|
||||
pass
|
||||
|
||||
|
||||
class FrpConflictError(FrpError):
|
||||
pass
|
||||
|
||||
|
||||
class FrpServiceError(FrpError):
|
||||
pass
|
||||
|
||||
|
||||
class FrpBackend(Protocol):
|
||||
def validate(self, path: Path) -> None: ...
|
||||
def status(self) -> dict[str, Any]: ...
|
||||
def select(self, path: Path) -> None: ...
|
||||
def start(self) -> None: ...
|
||||
def stop(self) -> None: ...
|
||||
def restart(self) -> None: ...
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _checked_uuid(value: str) -> str:
|
||||
try:
|
||||
checked = str(UUID(str(value)))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise FrpValidationError("FRP config id is invalid") from exc
|
||||
if checked != str(value):
|
||||
raise FrpValidationError("FRP config id is invalid")
|
||||
return checked
|
||||
|
||||
|
||||
def _checked_suffix(filename: str) -> str:
|
||||
suffix = Path(str(filename)).suffix.casefold()
|
||||
if suffix not in SUPPORTED_FRP_SUFFIXES:
|
||||
raise FrpValidationError("FRP config must be TOML, YAML, JSON or INI")
|
||||
return suffix
|
||||
|
||||
|
||||
def _checked_content(content: bytes) -> bytes:
|
||||
if not content:
|
||||
raise FrpValidationError("FRP config must not be empty")
|
||||
if len(content) > MAX_FRP_CONFIG_BYTES:
|
||||
raise FrpValidationError("FRP config exceeds the 1 MiB limit")
|
||||
try:
|
||||
text = content.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise FrpValidationError("FRP config must be UTF-8 text") from exc
|
||||
if "\x00" in text:
|
||||
raise FrpValidationError("FRP config must not contain NUL bytes")
|
||||
return content
|
||||
|
||||
|
||||
def _display_name(name: str, filename: str) -> str:
|
||||
candidate = str(name or "").strip() or Path(str(filename)).stem.strip()
|
||||
candidate = _SAFE_NAME_RE.sub("_", candidate).strip(" ._")
|
||||
if not candidate:
|
||||
candidate = "frpc"
|
||||
if len(candidate) > 80:
|
||||
candidate = candidate[:80].rstrip()
|
||||
return candidate
|
||||
|
||||
|
||||
def _checked_timestamp(value: str) -> str:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FrpValidationError("FRP config timestamp is invalid") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise FrpValidationError("FRP config timestamp is invalid")
|
||||
return value
|
||||
|
||||
|
||||
class FrpCatalog:
|
||||
def __init__(self, data_root: Path, backend: FrpBackend) -> None:
|
||||
self.root = Path(data_root) / "frp"
|
||||
self.catalog_path = self.root / "catalog.json"
|
||||
self.backend = backend
|
||||
self._lock = threading.RLock()
|
||||
self._error: str | None = None
|
||||
self._document = {
|
||||
"schema_version": FRP_CATALOG_SCHEMA_VERSION,
|
||||
"selected_id": None,
|
||||
"profiles": [],
|
||||
}
|
||||
try:
|
||||
self._load()
|
||||
except FrpError as exc:
|
||||
self._error = str(exc)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._error is None
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
return self._error
|
||||
|
||||
def _require_available(self) -> None:
|
||||
if self._error is not None:
|
||||
raise FrpServiceError(self._error)
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.catalog_path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self.catalog_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise FrpServiceError("FRP config catalog is damaged; the original file was preserved") from exc
|
||||
if not isinstance(raw, dict) or set(raw) != {"schema_version", "selected_id", "profiles"}:
|
||||
raise FrpServiceError("FRP config catalog fields are unsupported; the original file was preserved")
|
||||
if raw["schema_version"] != FRP_CATALOG_SCHEMA_VERSION:
|
||||
raise FrpServiceError("FRP config catalog version is unsupported; the original file was preserved")
|
||||
if not isinstance(raw["profiles"], list):
|
||||
raise FrpServiceError("FRP config catalog profiles are invalid; the original file was preserved")
|
||||
seen: set[str] = set()
|
||||
profiles: list[dict[str, str]] = []
|
||||
for item in raw["profiles"]:
|
||||
if not isinstance(item, dict) or set(item) != {"id", "name", "filename", "created_at", "updated_at"}:
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
|
||||
profile_id = _checked_uuid(item["id"])
|
||||
if profile_id in seen or not all(isinstance(item[key], str) and item[key] for key in item):
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
|
||||
suffix = _checked_suffix(item["filename"])
|
||||
if item["filename"] != f"frpc{suffix}" or _display_name(item["name"], item["filename"]) != item["name"]:
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
|
||||
try:
|
||||
_checked_timestamp(item["created_at"])
|
||||
_checked_timestamp(item["updated_at"])
|
||||
except FrpValidationError as exc:
|
||||
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved") from exc
|
||||
path = self._profile_path(item)
|
||||
if not path.is_file():
|
||||
raise FrpServiceError("FRP config catalog references a missing file; the original catalog was preserved")
|
||||
seen.add(profile_id)
|
||||
profiles.append(dict(item))
|
||||
selected = raw["selected_id"]
|
||||
if selected is not None and (not isinstance(selected, str) or selected not in seen):
|
||||
raise FrpServiceError("FRP selected config is invalid; the original catalog was preserved")
|
||||
self._document = {"schema_version": FRP_CATALOG_SCHEMA_VERSION, "selected_id": selected, "profiles": profiles}
|
||||
|
||||
def _profile_path(self, profile: dict[str, str]) -> Path:
|
||||
return self.root / "profiles" / profile["id"] / profile["filename"]
|
||||
|
||||
@staticmethod
|
||||
def _verification_path(path: Path) -> Path:
|
||||
return path.with_name(f".{path.stem}.verify{path.suffix}")
|
||||
|
||||
def _save(self) -> None:
|
||||
content = (json.dumps(self._document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
atomic_write_bytes(self.catalog_path, content)
|
||||
self._protect(self.catalog_path)
|
||||
|
||||
@staticmethod
|
||||
def _protect(path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
path.chmod(0o640)
|
||||
|
||||
def _unique_name(self, requested: str, *, excluding: str | None = None) -> str:
|
||||
existing = {
|
||||
item["name"].casefold()
|
||||
for item in self._document["profiles"]
|
||||
if item["id"] != excluding
|
||||
}
|
||||
if requested.casefold() not in existing:
|
||||
return requested
|
||||
counter = 2
|
||||
while f"{requested} ({counter})".casefold() in existing:
|
||||
counter += 1
|
||||
return f"{requested} ({counter})"
|
||||
|
||||
def list_document(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"available": self.available,
|
||||
"error": self.error,
|
||||
"selected_id": self._document["selected_id"],
|
||||
"profiles": deepcopy(self._document["profiles"]),
|
||||
}
|
||||
|
||||
def get(self, profile_id: str) -> tuple[dict[str, str], Path]:
|
||||
self._require_available()
|
||||
checked = _checked_uuid(profile_id)
|
||||
for profile in self._document["profiles"]:
|
||||
if profile["id"] == checked:
|
||||
return deepcopy(profile), self._profile_path(profile)
|
||||
raise FrpValidationError("FRP config was not found")
|
||||
|
||||
def create(self, *, filename: str, name: str, content: bytes) -> dict[str, str]:
|
||||
self._require_available()
|
||||
checked_content = _checked_content(content)
|
||||
suffix = _checked_suffix(filename)
|
||||
with self._lock:
|
||||
profile_id = str(uuid4())
|
||||
now = _utc_now()
|
||||
profile = {
|
||||
"id": profile_id,
|
||||
"name": self._unique_name(_display_name(name, filename)),
|
||||
"filename": f"frpc{suffix}",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
path = self._profile_path(profile)
|
||||
path.parent.mkdir(parents=True, exist_ok=False)
|
||||
if os.name != "nt":
|
||||
path.parent.chmod(0o2750)
|
||||
try:
|
||||
temporary = self._verification_path(path)
|
||||
atomic_write_bytes(temporary, checked_content)
|
||||
self.backend.validate(temporary)
|
||||
os.replace(temporary, path)
|
||||
self._protect(path)
|
||||
self._document["profiles"].append(profile)
|
||||
if self._document["selected_id"] is None:
|
||||
self._document["selected_id"] = profile_id
|
||||
self.backend.select(path)
|
||||
self._save()
|
||||
except BaseException:
|
||||
temporary = self._verification_path(path)
|
||||
temporary.unlink(missing_ok=True)
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
try:
|
||||
path.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
self._document["profiles"] = [item for item in self._document["profiles"] if item["id"] != profile_id]
|
||||
if self._document["selected_id"] == profile_id:
|
||||
self._document["selected_id"] = None
|
||||
raise
|
||||
return deepcopy(profile)
|
||||
|
||||
def update(self, profile_id: str, *, name: str, content: bytes) -> dict[str, str]:
|
||||
self._require_available()
|
||||
checked_content = _checked_content(content)
|
||||
with self._lock:
|
||||
profile, path = self.get(profile_id)
|
||||
old_content = path.read_bytes()
|
||||
temporary = self._verification_path(path)
|
||||
atomic_write_bytes(temporary, checked_content)
|
||||
try:
|
||||
self.backend.validate(temporary)
|
||||
os.replace(temporary, path)
|
||||
self._protect(path)
|
||||
profile["name"] = self._unique_name(_display_name(name, profile["filename"]), excluding=profile_id)
|
||||
profile["updated_at"] = _utc_now()
|
||||
index = next(i for i, item in enumerate(self._document["profiles"]) if item["id"] == profile_id)
|
||||
previous = self._document["profiles"][index]
|
||||
self._document["profiles"][index] = profile
|
||||
try:
|
||||
self._save()
|
||||
if self._document["selected_id"] == profile_id and self.backend.status()["running"]:
|
||||
self.backend.restart()
|
||||
except BaseException:
|
||||
atomic_write_bytes(path, old_content)
|
||||
self._protect(path)
|
||||
self._document["profiles"][index] = previous
|
||||
self._save()
|
||||
if self._document["selected_id"] == profile_id and self.backend.status()["running"]:
|
||||
try:
|
||||
self.backend.restart()
|
||||
except FrpError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return deepcopy(profile)
|
||||
|
||||
def select(self, profile_id: str) -> dict[str, str]:
|
||||
self._require_available()
|
||||
with self._lock:
|
||||
profile, path = self.get(profile_id)
|
||||
previous_id = self._document["selected_id"]
|
||||
previous_path = self.get(previous_id)[1] if previous_id else None
|
||||
was_running = bool(self.backend.status()["running"])
|
||||
try:
|
||||
if was_running:
|
||||
self.backend.stop()
|
||||
self.backend.select(path)
|
||||
self._document["selected_id"] = profile_id
|
||||
self._save()
|
||||
if was_running:
|
||||
self.backend.start()
|
||||
except BaseException:
|
||||
self._document["selected_id"] = previous_id
|
||||
if previous_path is not None:
|
||||
self.backend.select(previous_path)
|
||||
self._save()
|
||||
if was_running:
|
||||
try:
|
||||
self.backend.start()
|
||||
except FrpError:
|
||||
pass
|
||||
raise
|
||||
return profile
|
||||
|
||||
def delete(self, profile_id: str) -> None:
|
||||
self._require_available()
|
||||
with self._lock:
|
||||
profile, path = self.get(profile_id)
|
||||
selected = self._document["selected_id"] == profile_id
|
||||
if selected and self.backend.status()["running"]:
|
||||
raise FrpConflictError("stop FRP before deleting the active config")
|
||||
old_content = path.read_bytes()
|
||||
old_document = deepcopy(self._document)
|
||||
self._document["profiles"] = [item for item in self._document["profiles"] if item["id"] != profile_id]
|
||||
if selected:
|
||||
self._document["selected_id"] = None
|
||||
try:
|
||||
self._save()
|
||||
path.unlink()
|
||||
path.parent.rmdir()
|
||||
except BaseException:
|
||||
self._document = old_document
|
||||
atomic_write_bytes(path, old_content)
|
||||
self._protect(path)
|
||||
self._save()
|
||||
raise
|
||||
|
||||
|
||||
class SystemdFrpBackend:
|
||||
def __init__(
|
||||
self,
|
||||
data_root: Path,
|
||||
*,
|
||||
binary: Path = Path("/usr/local/bin/frpc"),
|
||||
unit: str = "matrix-screen-frpc.service",
|
||||
runner=subprocess.run,
|
||||
) -> None:
|
||||
self.binary = Path(binary)
|
||||
self.unit = unit
|
||||
self.environment_path = Path(data_root) / "frp" / "active.env"
|
||||
self.runner = runner
|
||||
|
||||
def _run(self, command: list[str], *, check: bool = True, timeout: int = 15) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
result = self.runner(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise FrpServiceError("FRP system service command failed") from exc
|
||||
if check and result.returncode != 0:
|
||||
raise FrpServiceError("FRP system service rejected the operation")
|
||||
return result
|
||||
|
||||
def validate(self, path: Path) -> None:
|
||||
if not self.binary.is_file():
|
||||
raise FrpServiceError("frpc is not installed")
|
||||
result = self._run([
|
||||
str(self.binary), "verify", "-c", str(path),
|
||||
"--allow-unsafe=TokenSourceExec",
|
||||
], check=False)
|
||||
if result.returncode != 0:
|
||||
message = (result.stderr or result.stdout or "FRP config validation failed").strip()
|
||||
message = re.sub(r"(?i)(token|password|secret)(\s*[=:]\s*)\S+", r"\1\2[redacted]", message)
|
||||
raise FrpValidationError(message[:1000])
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
version = None
|
||||
if self.binary.is_file():
|
||||
result = self._run([str(self.binary), "--version"], check=False, timeout=5)
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip()[:80]
|
||||
enabled_result = self._run(["/usr/bin/systemctl", "is-enabled", self.unit], check=False, timeout=5)
|
||||
active_result = self._run(["/usr/bin/systemctl", "is-active", self.unit], check=False, timeout=5)
|
||||
enabled = enabled_result.stdout.strip() == "enabled"
|
||||
state = active_result.stdout.strip() or "unknown"
|
||||
return {
|
||||
"installed": self.binary.is_file(),
|
||||
"version": version,
|
||||
"enabled": enabled,
|
||||
"running": state == "active",
|
||||
"state": state,
|
||||
"last_error": None if state in {"active", "inactive"} else "FRP service is not running normally",
|
||||
}
|
||||
|
||||
def select(self, path: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
content = f"FRPC_CONFIG={resolved}\n".encode("utf-8")
|
||||
atomic_write_bytes(self.environment_path, content)
|
||||
if os.name != "nt":
|
||||
self.environment_path.chmod(0o600)
|
||||
|
||||
def start(self) -> None:
|
||||
self._run(["/usr/bin/systemctl", "enable", "--now", self.unit])
|
||||
if not self.status()["running"]:
|
||||
raise FrpServiceError("FRP service did not enter the running state")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._run(["/usr/bin/systemctl", "disable", "--now", self.unit])
|
||||
|
||||
def restart(self) -> None:
|
||||
self._run(["/usr/bin/systemctl", "restart", self.unit])
|
||||
if not self.status()["running"]:
|
||||
raise FrpServiceError("FRP service did not restart successfully")
|
||||
|
||||
|
||||
class MockFrpBackend:
|
||||
def __init__(self) -> None:
|
||||
self.installed = True
|
||||
self.enabled = False
|
||||
self.running = False
|
||||
self.selected: Path | None = None
|
||||
self.fail_validation = False
|
||||
self.fail_start = False
|
||||
self.fail_restart = False
|
||||
|
||||
def validate(self, path: Path) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if self.fail_validation or "INVALID_FRP_CONFIG" in text:
|
||||
raise FrpValidationError("FRP config validation failed")
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"installed": self.installed,
|
||||
"version": "0.71.0-mock",
|
||||
"enabled": self.enabled,
|
||||
"running": self.running,
|
||||
"state": "active" if self.running else "inactive",
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
def select(self, path: Path) -> None:
|
||||
self.selected = Path(path)
|
||||
|
||||
def start(self) -> None:
|
||||
if self.fail_start:
|
||||
raise FrpServiceError("FRP service did not enter the running state")
|
||||
self.enabled = True
|
||||
self.running = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.enabled = False
|
||||
self.running = False
|
||||
|
||||
def restart(self) -> None:
|
||||
if self.fail_restart:
|
||||
raise FrpServiceError("FRP service did not restart successfully")
|
||||
self.running = True
|
||||
|
||||
|
||||
class FrpController:
|
||||
def __init__(self, data_root: Path, backend: FrpBackend) -> None:
|
||||
self.backend = backend
|
||||
self.catalog = FrpCatalog(data_root, backend)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
catalog = self.catalog.list_document()
|
||||
service = self.backend.status()
|
||||
if not catalog["available"]:
|
||||
service = {**service, "enabled": False, "running": False, "last_error": catalog["error"]}
|
||||
return {**catalog, "service": service}
|
||||
|
||||
def content(self, profile_id: str) -> tuple[dict[str, str], bytes]:
|
||||
profile, path = self.catalog.get(profile_id)
|
||||
return profile, path.read_bytes()
|
||||
|
||||
def start(self) -> dict[str, Any]:
|
||||
state = self.catalog.list_document()
|
||||
if not state["selected_id"]:
|
||||
raise FrpConflictError("select an FRP config before starting")
|
||||
_profile, path = self.catalog.get(state["selected_id"])
|
||||
self.backend.validate(path)
|
||||
self.backend.select(path)
|
||||
self.backend.start()
|
||||
return self.status()
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
self.backend.stop()
|
||||
return self.status()
|
||||
Reference in New Issue
Block a user