diff options
Diffstat (limited to 'packages/meshbay-node/src')
11 files changed, 157 insertions, 57 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 78ee873..cfbec50 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -11,6 +11,8 @@ import os from dataclasses import dataclass, field from pathlib import Path +from meshbay_node.platform import config_dir, data_dir + try: import tomllib # Python 3.11+ except ImportError: @@ -25,7 +27,7 @@ DEFAULT_STUN_SERVERS: list[str] = [ "stun:stun.services.mozilla.com:3478", ] -DEFAULT_CONFIG_PATH = Path.home() / ".config" / "meshbay" / "node.toml" +DEFAULT_CONFIG_PATH = config_dir() / "node.toml" EXAMPLE_CONFIG = """\ # MeshBay Node configuration — multi-group example @@ -169,6 +171,8 @@ class NodeConfig: # machine with a Tailscale wt0 interface. ice_interfaces: list[str] = field(default_factory=list) # include-list overrides auto stun_servers: list[str] = field(default_factory=list) # empty = DEFAULT_STUN_SERVERS + ffmpeg_path: str = "ffmpeg" + ffprobe_path: str = "ffprobe" @dataclass @@ -234,7 +238,7 @@ class Config: node: NodeConfig = field(default_factory=NodeConfig) groups: list[GroupConfig] = field(default_factory=list) keystore: KeystoreConfig = field(default_factory=KeystoreConfig) - data_dir: Path = field(default_factory=lambda: Path.home() / ".local" / "share" / "meshbay") + data_dir: Path = field(default_factory=data_dir) # Back-compat: single-group access @property @@ -333,6 +337,10 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: stun = nd.get("stun_servers") if isinstance(stun, list): cfg.node.stun_servers = [str(s) for s in stun] + if "ffmpeg_path" in nd: + cfg.node.ffmpeg_path = str(nd["ffmpeg_path"]) + if "ffprobe_path" in nd: + cfg.node.ffprobe_path = str(nd["ffprobe_path"]) # Multi-group: [[groups]] array if "groups" in raw: diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 7341423..47241cd 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -52,6 +52,7 @@ from meshbay_node.media_cache import MediaCache from meshbay_node.tmdb import TmdbClient from meshbay_node.musicbrainz import MusicBrainzClient from meshbay_node.keystore import create_keystore, load_keystore, load_or_create_keystore +from meshbay_node.platform import chmod_private, config_dir, data_dir, state_dir from meshbay_node.roster import Roster from meshbay_node.transport import ( Denylist, @@ -219,7 +220,7 @@ class NodeDaemon: self._config.data_dir.mkdir(parents=True, exist_ok=True) self._ui_token_file = self._config.data_dir / "ui-token" self._ui_token_file.write_text(ui_token) - self._ui_token_file.chmod(0o600) + chmod_private(self._ui_token_file) from meshbay_node.ui import create_ui_app ui_app = create_ui_app(self._state) ui_cfg = uvicorn.Config( @@ -677,8 +678,12 @@ class NodeDaemon: # 12. Wait for shutdown stop_event = asyncio.Event() loop = asyncio.get_event_loop() - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, stop_event.set) + if sys.platform == "win32": + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, lambda *_: stop_event.set()) + else: + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) # Milestone 14.8: re-read node.toml without dropping connections. try: loop.add_signal_handler( @@ -1734,7 +1739,7 @@ def main() -> None: "", ] cfg_path.write_text("\n".join(toml_lines) + "\n") - os.chmod(cfg_path, 0o600) + chmod_private(cfg_path) print(f"Config written to {cfg_path}") unlock_file = config_dir / "unlock.key" @@ -1742,7 +1747,7 @@ def main() -> None: import secrets key = secrets.token_urlsafe(32) unlock_file.write_text(key + "\n") - os.chmod(unlock_file, 0o600) + chmod_private(unlock_file) print(f"Unlock key created: {unlock_file}") cfg = load_config(cfg_path) @@ -1759,7 +1764,10 @@ def main() -> None: print() print("Next steps:") print(f" 1. Link this node key on {hub_url} → Settings → Link Node") - print(" 2. systemctl --user enable --now meshbay-node") + if sys.platform == "win32": + print(" 2. meshbay-node (start the daemon)") + else: + print(" 2. systemctl --user enable --now meshbay-node") print(" 3. meshbay-node group add <name> --dir /path/to/files") print(" 4. meshbay-node gek init") print(" 5. meshbay-node operator pair") @@ -1768,12 +1776,12 @@ def main() -> None: if args.command == "reset": import shutil - config_dir = Path.home() / ".config" / "meshbay" - data_dir = Path.home() / ".local" / "share" / "meshbay" - state_dir = Path.home() / ".local" / "state" / "meshbay" + config_dir_ = config_dir() + data_dir_ = data_dir() + state_dir_ = state_dir() items = [] - for d in (config_dir, data_dir): + for d in (config_dir_, data_dir_): if d.exists(): for child in sorted(d.iterdir()): items.append(child) @@ -1800,10 +1808,10 @@ def main() -> None: import urllib.request import urllib.error - token_file = data_dir / "ui-token" + token_file = data_dir_ / "ui-token" if token_file.exists(): try: - cfg = Config(config_dir / "node.toml") + cfg = Config(config_dir_ / "node.toml") tok = token_file.read_text().strip() url = (f"http://127.0.0.1:{cfg.node.ui_port}" f"/api/unlink?t={tok}") @@ -1814,16 +1822,17 @@ def main() -> None: except Exception: print("Could not unlink from hub (daemon not reachable).") - _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"], - capture_output=True) + if sys.platform != "win32": + _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"], + capture_output=True) - for d in (config_dir, data_dir): + for d in (config_dir_, data_dir_): if d.exists(): shutil.rmtree(d) print(f"Removed {d}") - if state_dir.is_dir(): - shutil.rmtree(state_dir) - print(f"Removed {state_dir}") + if state_dir_.is_dir(): + shutil.rmtree(state_dir_) + print(f"Removed {state_dir_}") print("Node state erased. Run 'meshbay-node init' to start over.") return @@ -2056,16 +2065,9 @@ def main() -> None: return if args.command == "reload": - # Milestone 14.8. The daemon re-reads node.toml; groups that appeared or - # whose roots changed are picked up without dropping live connections. - # - # Delegated to systemd rather than hunting a PID with pgrep and signalling - # it directly: an unanchored (or merely unlucky) pattern match there has - # already SIGHUPed a developer's own running node by accident — see the - # comment this replaced, and test_cli_dispatch.py's stub_daemon fixture, - # which had to stub os.kill for exactly that reason. The unit already - # declares `ExecReload=/bin/kill -HUP $MAINPID`, so systemd sends the - # signal to the one process it actually started. + if sys.platform == "win32": + print("reload is not supported on Windows — restart the daemon instead.") + sys.exit(1) _systemctl_user( "reload", "meshbay-node", not_running_hint="Node is not running as a systemd unit — start it " @@ -2075,9 +2077,10 @@ def main() -> None: return if args.command == "restart-daemon": - # Same reasoning as reload: no PID hunting, no manual respawn — systemd - # already knows how to stop and start this unit, and does not need this - # process to guess where its log file is. + if sys.platform == "win32": + print("restart-daemon is not supported on Windows — stop and start " + "the daemon manually.") + sys.exit(1) _systemctl_user( "restart", "meshbay-node", not_running_hint="meshbay-node is not installed as a systemd unit — " @@ -2356,6 +2359,13 @@ def main() -> None: print("Error: hub.username not set in config. Run: meshbay-node init") sys.exit(1) + from meshbay_node.platform import check_media_tools + try: + check_media_tools(cfg.node.ffmpeg_path, cfg.node.ffprobe_path) + except RuntimeError as e: + print(f"Error: {e}") + sys.exit(1) + daemon = NodeDaemon(cfg, Path(args.config or DEFAULT_CONFIG_PATH)) asyncio.run(daemon.run()) diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 1ed1662..1e975fa 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -26,6 +26,7 @@ import httpx import jwt from meshbay_node.keystore import NodeKeys +from meshbay_node.platform import config_dir log = logging.getLogger(__name__) @@ -61,7 +62,7 @@ class HubSession: class HubConfig: hub_url: str username: str - cache_dir: Path = field(default_factory=lambda: Path.home() / ".config" / "meshbay") + cache_dir: Path = field(default_factory=config_dir) @property def hub_pk_cache_path(self) -> Path: diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py index 8b2eca5..19ab9ce 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py @@ -161,8 +161,9 @@ def _synthetic_episode_number(file_path: Path, show_root: Path, season: int) -> async def _make_thumbnail(file_path: Path, duration: float | None) -> bytes | None: """One ffmpeg frame grab at ~10% of duration (or 5s if unknown), scaled down.""" seek = max(0.0, (duration or 50.0) * 0.1) + from meshbay_node.platform import ffmpeg_cmd proc = await asyncio.create_subprocess_exec( - "ffmpeg", "-v", "error", "-ss", str(seek), "-i", str(file_path), + ffmpeg_cmd(), "-v", "error", "-ss", str(seek), "-i", str(file_path), "-frames:v", "1", "-vf", f"scale={THUMB_WIDTH}:-1", "-f", "image2", "-c:v", "mjpeg", "pipe:1", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, diff --git a/packages/meshbay-node/src/meshbay_node/keystore.py b/packages/meshbay-node/src/meshbay_node/keystore.py index 59fc719..dff443d 100644 --- a/packages/meshbay-node/src/meshbay_node/keystore.py +++ b/packages/meshbay-node/src/meshbay_node/keystore.py @@ -31,6 +31,7 @@ import getpass import json import logging import os +import sys from dataclasses import dataclass from pathlib import Path @@ -38,6 +39,7 @@ import msgpack from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from meshbay_node.platform import chmod_private, config_dir from meshbay_common.crypto import ( ARGON2_ITERATIONS, ARGON2_LANES, @@ -57,8 +59,8 @@ from meshbay_common.crypto import ( log = logging.getLogger(__name__) KEYSTORE_VERSION = 1 -DEFAULT_KEYSTORE_PATH = Path.home() / ".config" / "meshbay" / "keystore.enc" -DEFAULT_UNLOCK_FILE = Path.home() / ".config" / "meshbay" / "unlock.key" +DEFAULT_KEYSTORE_PATH = config_dir() / "keystore.enc" +DEFAULT_UNLOCK_FILE = config_dir() / "unlock.key" @dataclass @@ -94,12 +96,14 @@ def _resolve_password(unlock_file: Path | None = None) -> str: # 2. Unlock key file key_file = unlock_file or DEFAULT_UNLOCK_FILE if key_file.exists(): - mode = oct(key_file.stat().st_mode)[-3:] - if mode != "600": - log.warning( - "unlock.key permissions are %s (expected 600) — fix with: chmod 600 %s", - mode, key_file, - ) + if sys.platform != "win32": + mode = oct(key_file.stat().st_mode)[-3:] + if mode != "600": + log.warning( + "unlock.key permissions are %s (expected 600) — fix with: " + "chmod 600 %s", + mode, key_file, + ) log.debug("Keystore password from %s", key_file) return key_file.read_text().strip() @@ -233,7 +237,7 @@ def _write_keystore(path: Path, keys: NodeKeys, password: str) -> None: "ciphertext_b64": base64.b64encode(ct).decode(), } path.write_text(json.dumps(envelope, indent=2)) - path.chmod(0o600) + chmod_private(path) def load_or_create_keystore( diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py index 6dd3baa..e0c59e3 100644 --- a/packages/meshbay-node/src/meshbay_node/media_probe.py +++ b/packages/meshbay-node/src/meshbay_node/media_probe.py @@ -47,8 +47,9 @@ async def probe_video( field, no second process spawn) — resolution is deliberately never guessed from the filename (docs/mediacenter.md §3.5). """ + from meshbay_node.platform import ffprobe_cmd proc = await asyncio.create_subprocess_exec( - "ffprobe", "-v", "error", + ffprobe_cmd(), "-v", "error", "-show_entries", "stream=codec_name,profile,level,codec_type,width,height", "-show_entries", "format=duration", "-of", "json", path, diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py new file mode 100644 index 0000000..bc48169 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -0,0 +1,70 @@ +"""Platform-specific paths and tool resolution for meshbay-node.""" + +import os +import shutil +import sys +from pathlib import Path + +# ── Directories ────────────────────────────────────────────────────────────── + + +def config_dir() -> Path: + if sys.platform == "win32": + return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "meshbay" + return Path.home() / ".config" / "meshbay" + + +def data_dir() -> Path: + if sys.platform == "win32": + return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "meshbay" / "data" + return Path.home() / ".local" / "share" / "meshbay" + + +def state_dir() -> Path: + if sys.platform == "win32": + return Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "meshbay" / "state" + return Path.home() / ".local" / "state" / "meshbay" + + +# ── File permissions ───────────────────────────────────────────────────────── + + +def chmod_private(path: Path, *, mode: int = 0o600) -> None: + """Set restrictive permissions on a file. No-op on Windows (NTFS ignores mode bits).""" + if sys.platform != "win32": + path.chmod(mode) + + +# ── Media tools ────────────────────────────────────────────────────────────── + +_ffmpeg_path: str = "ffmpeg" +_ffprobe_path: str = "ffprobe" + + +def check_media_tools( + ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe", +) -> None: + """Resolve ffmpeg/ffprobe at daemon startup. Raises RuntimeError if not found.""" + global _ffmpeg_path, _ffprobe_path + resolved = shutil.which(ffmpeg) + if not resolved: + raise RuntimeError( + f"{ffmpeg!r} not found in PATH. " + "Install ffmpeg or set [node] ffmpeg_path in node.toml." + ) + _ffmpeg_path = resolved + resolved = shutil.which(ffprobe) + if not resolved: + raise RuntimeError( + f"{ffprobe!r} not found in PATH. " + "Install ffmpeg or set [node] ffprobe_path in node.toml." + ) + _ffprobe_path = resolved + + +def ffmpeg_cmd() -> str: + return _ffmpeg_path + + +def ffprobe_cmd() -> str: + return _ffprobe_path diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 5af2b55..a50dd3e 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -914,6 +914,7 @@ def write_code_file(data_dir: Path, code: str, expires_at: str, """ path = data_dir / name path.parent.mkdir(parents=True, exist_ok=True) + from meshbay_node.platform import chmod_private path.write_text(f"{code}\nexpires {expires_at}\n") - os.chmod(path, 0o600) + chmod_private(path) return path diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index 6dde3ff..95fccf1 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -53,6 +53,7 @@ from meshbay_common.groupbox import PURPOSE_ACK, seal from meshbay_common.protocol import MNP, file_chunk_wire from meshbay_node.indexer import GroupIndex from meshbay_node.transport.wire import index_sync_message +from meshbay_node import platform log = logging.getLogger(__name__) @@ -545,7 +546,7 @@ def _extract_segment(file_path: Path, start_time: float, duration: float) -> byt """Extract one HLS segment via ffmpeg. Returns MPEG-TS bytes or None on failure.""" try: result = subprocess.run( - ["ffmpeg", "-hide_banner", "-loglevel", "error", + [platform.ffmpeg_cmd(), "-hide_banner", "-loglevel", "error", "-ss", str(start_time), "-i", str(file_path), "-t", str(duration), @@ -596,8 +597,9 @@ class QuicChunkServer: # Peer sets are per group now — see _MNPServerProtocol._peer_registry(). self._host = host self._port = port - self._cert_path = cert_path or Path.home() / ".config/meshbay/node_tls.crt" - self._key_path = key_path or Path.home() / ".config/meshbay/node_tls.key" + from meshbay_node.platform import config_dir + self._cert_path = cert_path or config_dir() / "node_tls.crt" + self._key_path = key_path or config_dir() / "node_tls.key" self._server = None self._task = None self._session_tickets: dict[bytes, Any] = {} diff --git a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py index 374fd08..8d680ea 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py +++ b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py @@ -21,10 +21,12 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID +from meshbay_node.platform import chmod_private, config_dir + log = logging.getLogger(__name__) -DEFAULT_CERT = Path.home() / ".config" / "meshbay" / "node_tls.crt" -DEFAULT_KEY = Path.home() / ".config" / "meshbay" / "node_tls.key" +DEFAULT_CERT = config_dir() / "node_tls.crt" +DEFAULT_KEY = config_dir() / "node_tls.key" def generate_self_signed_cert( @@ -64,8 +66,8 @@ def generate_self_signed_cert( serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption(), )) - cert_path.chmod(0o644) - key_path.chmod(0o600) + chmod_private(cert_path, mode=0o644) + chmod_private(key_path) log.info("TLS cert generated: %s", cert_path) return cert_path, key_path diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 0154319..e333ae8 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -100,7 +100,7 @@ from meshbay_common.protocol import MNP, chunk_ciphertext, file_chunk_wire from meshbay_node.transport.wire import index_sync_message from meshbay_node.indexer import GroupIndex from meshbay_node.indexer.indexer import DirectoryIndexer -from meshbay_node import linkpreview, ops +from meshbay_node import linkpreview, ops, platform # Re-imported under its original name: every call site and existing test in # this module still refers to it as `_probe_video`. The implementation lives # in media_probe.py so the indexer package (imported just above) can call it @@ -3441,7 +3441,7 @@ class WebRTCPeerSession: try: async with sem: proc = await asyncio.create_subprocess_exec( - "ffmpeg", "-hide_banner", "-loglevel", "error", + platform.ffmpeg_cmd(), "-hide_banner", "-loglevel", "error", "-ss", str(segment_index * segment_duration), "-i", str(file_path), "-t", str(segment_duration), @@ -4360,7 +4360,7 @@ class WebRTCPeerSession: # forced out of its MediaSource with no further explanation. codec_args += ["-c:a", "aac", "-ac", "2", "-b:a", "192k"] proc = await asyncio.create_subprocess_exec( - "ffmpeg", "-hide_banner", "-loglevel", "error", + platform.ffmpeg_cmd(), "-hide_banner", "-loglevel", "error", *seek_args, "-i", str(file_path), *map_args, @@ -4556,7 +4556,7 @@ async def _transcode_audio_to_aac(file_path: Path) -> bytes: tmp_path = Path(tmp_name) try: proc = await asyncio.create_subprocess_exec( - "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + platform.ffmpeg_cmd(), "-hide_banner", "-loglevel", "error", "-y", "-i", str(file_path), "-vn", "-c:a", "aac", "-ac", "2", "-b:a", "192k", "-f", "ipod", str(tmp_path), |