497 lines
19 KiB
Python
497 lines
19 KiB
Python
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()
|