diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b846ef..3f6f09ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to this project will be documented in this file. - Post-install prompts for existing users after upgrades - Coolify-oriented Docker Compose with resource limits for deployments - LXST telephony half-duplex mode, live duplex switching, push-to-talk (packetizer squelch), and richer in-call stats (rates plus mute state on the active call) +- Optional Linux seccomp-BPF syscall denylist (libseccomp) alongside Landlock, with auto-detect and `MESHCHAT_SECCOMP` fallback ### Changed diff --git a/docs/agents/skills/landlock-sqlite/SKILL.md b/docs/agents/skills/landlock-sqlite/SKILL.md index 3651f37b..fe7a76de 100644 --- a/docs/agents/skills/landlock-sqlite/SKILL.md +++ b/docs/agents/skills/landlock-sqlite/SKILL.md @@ -40,4 +40,5 @@ For live stress, run Landlock in a **subprocess** (sandbox applies once per proc - `meshchatx/src/backend/memory_pressure.py` - `meshchatx/src/backend/message_handler.py` - `meshchatx/src/backend/landlock_sandbox.py` +- `meshchatx/src/backend/seccomp_sandbox.py` (syscall denylist after Landlock) - `meshchatx/meshchat.py` (conversations/notifications error mapping) diff --git a/docs/en/identity-and-security.md b/docs/en/identity-and-security.md index 7463ab7f..4dc85043 100644 --- a/docs/en/identity-and-security.md +++ b/docs/en/identity-and-security.md @@ -57,7 +57,19 @@ Privacy mode does not disable Reticulum mesh traffic. It limits clearnet fetches ## Linux sandboxing -Optional Landlock sandboxing on Linux restricts filesystem access for the backend. See **Linux sandboxing** in Platform guides for Firejail and Bubblewrap examples. +On Linux, MeshChatX can enable two complementary in-process sandboxes when supported: + +- **Landlock** restricts filesystem paths the backend may use +- **Seccomp-BPF** installs a syscall denylist (via libseccomp) that blocks kernel-admin and related calls a mesh client does not need + +Both auto-enable when available and fall back to a no-op when the platform, kernel, or libraries cannot support them. Override with: + +- `MESHCHAT_LANDLOCK=0` or `1` +- `MESHCHAT_SECCOMP=0` or `1` + +Android never enables these in-process sandboxes (the Android app seccomp policy already constrains the process, and Landlock syscalls are blocked there). + +See **Linux sandboxing** in Platform guides for optional Firejail and Bubblewrap wrappers around the host install. ## Blocking and filtering diff --git a/docs/en/platform-guides/linux-sandbox.md b/docs/en/platform-guides/linux-sandbox.md index 52a0b21c..294c1ec3 100644 --- a/docs/en/platform-guides/linux-sandbox.md +++ b/docs/en/platform-guides/linux-sandbox.md @@ -4,6 +4,13 @@ This page shows how to run **`meshchatx`** under **Firejail** or **Bubblewrap** These tools do **not** replace a full virtual machine or hardware-enforced boundary. They reduce exposure of your home directory and other paths the process can write to, when you configure them with tight whitelists or bind mounts. +MeshChatX also applies optional **in-process** Linux sandboxes when available: + +- **Landlock** for filesystem path rules (`MESHCHAT_LANDLOCK=0` to disable) +- **Seccomp-BPF** syscall denylist via libseccomp (`MESHCHAT_SECCOMP=0` to disable) + +Those layers fall back cleanly when unsupported. Firejail and Bubblewrap remain useful as an outer wrapper. + **Containers:** If you already run MeshChatX with Docker or Podman, that is a different isolation model, this document is aimed at **host-installed** `meshchatx` (or `meshchat`). ## Prerequisites diff --git a/meshchatx.rsm b/meshchatx.rsm index d58153f5..f30515c8 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 5fcf07ca..a805affc 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -105,6 +105,13 @@ from meshchatx.src.backend.landlock_sandbox import ( landlock_kernel_supported, landlock_requested, ) +from meshchatx.src.backend.seccomp_sandbox import ( + apply_seccomp_sandbox, + seccomp_auto_enabled, + seccomp_disabled_by_env, + seccomp_kernel_supported, + seccomp_requested, +) from meshchatx.src.backend.legacy_migrator import ( assert_migration_context_paths, fresh_storage_at_target, @@ -508,6 +515,7 @@ class ReticulumMeshChat: self.listen_port: int | None = None self.use_https: bool = True self.landlock_active: bool = False + self.seccomp_active: bool = False self._pending_identity = identity self._network_setup_lock = threading.Lock() self._network_ready_event = threading.Event() @@ -4912,6 +4920,11 @@ class ReticulumMeshChat: "landlock_auto_enabled": landlock_auto_enabled(), "landlock_disabled_by_env": landlock_disabled_by_env(), "landlock_active": self.landlock_active, + "seccomp_kernel_supported": seccomp_kernel_supported(), + "seccomp_requested": seccomp_requested(), + "seccomp_auto_enabled": seccomp_auto_enabled(), + "seccomp_disabled_by_env": seccomp_disabled_by_env(), + "seccomp_active": self.seccomp_active, } def get_routes(self): @@ -25036,6 +25049,8 @@ def main(): public_dir=reticulum_meshchat.public_dir_override or get_file_path("public"), log_dir=resolve_log_dir(), ) + # Apply after Landlock so landlock_* syscalls are not blocked by the filter. + reticulum_meshchat.seccomp_active = apply_seccomp_sandbox() reticulum_meshchat.run( args.host, args.port, diff --git a/meshchatx/src/backend/seccomp_sandbox.py b/meshchatx/src/backend/seccomp_sandbox.py new file mode 100644 index 00000000..516da5dd --- /dev/null +++ b/meshchatx/src/backend/seccomp_sandbox.py @@ -0,0 +1,268 @@ +# SPDX-License-Identifier: 0BSD + +"""Optional seccomp-BPF syscall denylist for the backend (Linux only). + +Complements Landlock filesystem rules. Default action is ALLOW with a small +denylist of kernel-admin and process-introspection syscalls a mesh client never +needs. When libseccomp or the kernel filter is unavailable, apply falls back to +a no-op so the process still starts. +""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import errno +import logging +import os +import sys + +logger = logging.getLogger("meshchatx.seccomp") + +# From linux/seccomp.h / libseccomp.h +_SCMP_ACT_ALLOW = 0x7FFF0000 +_SCMP_ACT_ERRNO_BASE = 0x00050000 + +# Dangerous or unused by MeshChatX. Names are resolved via libseccomp so unknown +# names on older kernels are skipped instead of failing the whole filter. +_DENIED_SYSCALLS = ( + "mount", + "umount", + "umount2", + "pivot_root", + "reboot", + "kexec_load", + "kexec_file_load", + "init_module", + "finit_module", + "delete_module", + "swapon", + "swapoff", + "bpf", + "userfaultfd", + "open_by_handle_at", + "name_to_handle_at", + "setns", + "unshare", + "acct", + "ioperm", + "iopl", + "perf_event_open", + "ptrace", + "process_vm_readv", + "process_vm_writev", + "syslog", + "quotactl", + "lookup_dcookie", + "fanotify_init", + "open_tree", + "move_mount", + "fsopen", + "fsconfig", + "fsmount", + "fspick", + "mount_setattr", + "vhangup", + "uselib", + "create_module", + "query_module", + "get_kernel_syms", + "nfsservctl", + "vm86", + "vm86old", + "_sysctl", + "modify_ldt", +) + + +def _seccomp_env_override() -> bool | None: + raw = os.environ.get("MESHCHAT_SECCOMP") + if raw is None: + return None + val = raw.strip().lower() + if val in ("false", "0", "no", "off"): + return False + if val in ("true", "1", "yes", "on"): + return True + return None + + +def _is_android() -> bool: + return hasattr(sys, "getandroidapilevel") + + +_seccomp_support_cached: bool | None = None +_seccomp_lib_cached = None +_seccomp_lib_failed = False + + +def _load_libseccomp(): + """Return a loaded libseccomp CDLL, or None when unavailable.""" + global _seccomp_lib_cached, _seccomp_lib_failed + if _seccomp_lib_failed: + return None + if _seccomp_lib_cached is not None: + return _seccomp_lib_cached + + candidates: list[str | None] = [ + ctypes.util.find_library("seccomp"), + "libseccomp.so.2", + "libseccomp.so", + ] + for name in candidates: + if not name: + continue + try: + lib = ctypes.CDLL(name) + except OSError: + continue + required = ( + "seccomp_init", + "seccomp_rule_add", + "seccomp_load", + "seccomp_release", + "seccomp_syscall_resolve_name", + ) + if any(not hasattr(lib, attr) for attr in required): + continue + + lib.seccomp_init.argtypes = [ctypes.c_uint32] + lib.seccomp_init.restype = ctypes.c_void_p + lib.seccomp_rule_add.argtypes = [ + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.c_int, + ctypes.c_uint, + ] + lib.seccomp_rule_add.restype = ctypes.c_int + lib.seccomp_load.argtypes = [ctypes.c_void_p] + lib.seccomp_load.restype = ctypes.c_int + lib.seccomp_release.argtypes = [ctypes.c_void_p] + lib.seccomp_release.restype = None + lib.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p] + lib.seccomp_syscall_resolve_name.restype = ctypes.c_int + + _seccomp_lib_cached = lib + return lib + + _seccomp_lib_failed = True + return None + + +def _act_errno(code: int = errno.EPERM) -> int: + return _SCMP_ACT_ERRNO_BASE | (int(code) & 0xFFFF) + + +def seccomp_library_available() -> bool: + """Return True when libseccomp can be loaded with the symbols we need.""" + return _load_libseccomp() is not None + + +def seccomp_kernel_supported() -> bool: + """Return True when this host can install a user seccomp-BPF filter.""" + global _seccomp_support_cached + if _seccomp_support_cached is not None: + return _seccomp_support_cached + if sys.platform != "linux" or _is_android(): + _seccomp_support_cached = False + return False + if not seccomp_library_available(): + _seccomp_support_cached = False + return False + # Probe by building an empty ALLOW filter without loading it. + lib = _load_libseccomp() + if lib is None: + _seccomp_support_cached = False + return False + ctx = lib.seccomp_init(_SCMP_ACT_ALLOW) + if not ctx: + _seccomp_support_cached = False + return False + lib.seccomp_release(ctx) + _seccomp_support_cached = True + return True + + +def seccomp_requested() -> bool: + if sys.platform != "linux" or _is_android(): + return False + override = _seccomp_env_override() + if override is False: + return False + if override is True: + return True + return seccomp_kernel_supported() + + +def seccomp_auto_enabled() -> bool: + return seccomp_requested() and _seccomp_env_override() is None + + +def seccomp_disabled_by_env() -> bool: + return _seccomp_env_override() is False + + +def apply_seccomp_sandbox() -> bool: + """Install the denylist filter. Returns True when seccomp-BPF is active. + + Falls back to False (no filter) when unsupported, forced off, or install + fails. Never raises into the caller for probe or load failures. + """ + if not seccomp_requested(): + return False + + lib = _load_libseccomp() + if lib is None: + logger.warning( + "Seccomp requested but libseccomp is unavailable; continuing without it", + ) + return False + + ctx = lib.seccomp_init(_SCMP_ACT_ALLOW) + if not ctx: + logger.warning("Seccomp disabled: seccomp_init failed") + return False + + denied = 0 + loaded = False + try: + for name in _DENIED_SYSCALLS: + nr = lib.seccomp_syscall_resolve_name(name.encode("ascii")) + if nr < 0: + continue + rc = lib.seccomp_rule_add(ctx, _act_errno(errno.EPERM), nr, 0) + if rc != 0: + logger.debug( + "Seccomp skip rule for %s: libseccomp rc %s", + name, + rc, + ) + continue + denied += 1 + + if denied == 0: + logger.warning("Seccomp disabled: no denylist rules could be installed") + return False + + rc = lib.seccomp_load(ctx) + if rc != 0: + logger.warning("Seccomp disabled: seccomp_load failed with rc %s", rc) + return False + loaded = True + except Exception as exc: + logger.warning("Seccomp disabled: %s", exc) + return False + finally: + lib.seccomp_release(ctx) + + if not loaded: + return False + + if seccomp_auto_enabled(): + logger.info( + "Seccomp-BPF syscall denylist enabled (auto-detected, %s rules)", + denied, + ) + else: + logger.info("Seccomp-BPF syscall denylist enabled (%s rules)", denied) + return True diff --git a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue index 2efc5638..2fd5916b 100644 --- a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue +++ b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue @@ -8,27 +8,32 @@ :description="$t('tools.rnstatus.description')" :eyebrow="$t('rnprobe.network_diagnostics')" accent="orange" - /> -
-
-
-
- + + +
+
+
+
+
+
-
-
- {{ $t("rnstatus.remote_query") }} -
-

- {{ $t("rnstatus.remote_query_hint") }} -

-
- - -
- -
- {{ - $t("rnstatus.remote_active", { hash: activeRemoteHash }) +
+

+ {{ $t("rnstatus.remote_query") }} +

+

+ {{ $t("rnstatus.remote_query_hint") }} +

+
+
+ + +
+ +
+ {{ + $t("rnstatus.remote_active", { hash: activeRemoteHash }) + }} + +
+
-
+
+
-
- {{ $t("rnstatus.active_links", { count: formatInt(linkCount) }) }} -
+ {{ $t("rnstatus.active_links", { count: formatInt(linkCount) }) }}
- -
-
- {{ +
+
+ {{ $t("rnstatus.blackhole_label", { state: blackholeEnabled ? $t("rnstatus.blackhole_publishing") : $t("rnstatus.blackhole_inactive"), }) - }} - - {{ formatInt(blackholeCount) }} Identities - + }} +
+
+ {{ formatInt(blackholeCount) }} Identities
@@ -129,14 +137,14 @@
-
Blackhole Sources
-
+

Blackhole Sources

+
{{ source }}
@@ -145,145 +153,161 @@
{{ $t("rnstatus.no_interfaces_found") }}
-
-
-
-
-

- {{ iface.name }} -

- - Discovered - +
+
+
+
+
+

+ {{ iface.name }} +

+ + Discovered + +
+ + {{ iface.status }} +
- - {{ iface.status }} - -
-
-
-
{{ $t("rnstatus.mode") }}
-
{{ iface.mode }}
-
-
-
{{ $t("rnstatus.bitrate") }}
-
{{ iface.bitrate }}
-
-
-
{{ $t("rnstatus.rx_bytes") }}
-
{{ iface.rx_bytes_str }}
-
-
-
{{ $t("rnstatus.tx_bytes") }}
-
{{ iface.tx_bytes_str }}
-
-
-
{{ $t("rnstatus.rx_packets") }}
-
- {{ iface.rx_packets }} +
+
+
{{ $t("rnstatus.mode") }}
+
{{ iface.mode }}
-
-
-
{{ $t("rnstatus.tx_packets") }}
-
- {{ iface.tx_packets }} +
+
{{ $t("rnstatus.bitrate") }}
+
{{ iface.bitrate }}
-
-
-
{{ $t("rnstatus.clients") }}
-
- {{ formatInt(iface.clients) }} +
+
+ {{ $t("rnstatus.rx_bytes") }} +
+
{{ iface.rx_bytes_str }}
-
-
-
Peers
-
- {{ formatInt(iface.peers) }} {{ $t("rnstatus.peers_reachable") }} +
+
+ {{ $t("rnstatus.tx_bytes") }} +
+
{{ iface.tx_bytes_str }}
-
-
-
{{ $t("rnstatus.noise_floor") }}
-
{{ iface.noise_floor }}
-
-
-
{{ $t("rnstatus.interference") }}
-
{{ iface.interference }}
-
-
-
{{ $t("rnstatus.cpu_load") }}
-
{{ iface.cpu_load }}
-
-
-
{{ $t("rnstatus.cpu_temp") }}
-
{{ iface.cpu_temp }}
-
-
-
{{ $t("rnstatus.memory_load") }}
-
{{ iface.mem_load }}
-
-
-
{{ $t("rnstatus.battery") }}
-
- {{ formatInt(iface.battery_percent) }}% - ({{ iface.battery_state }}) +
+
+ {{ $t("rnstatus.rx_packets") }} +
+
+ {{ iface.rx_packets }} +
-
-
-
{{ $t("rnstatus.network") }}
-
{{ iface.network_name }}
-
-
-
- {{ $t("rnstatus.incoming_announces") }} +
+
+ {{ $t("rnstatus.tx_packets") }} +
+
+ {{ iface.tx_packets }} +
-
- {{ iface.incoming_announce_frequency }}/s +
+
{{ $t("rnstatus.clients") }}
+
+ {{ formatInt(iface.clients) }} +
-
-
-
- {{ $t("rnstatus.outgoing_announces") }} +
+
Peers
+
+ {{ formatInt(iface.peers) }} {{ $t("rnstatus.peers_reachable") }} +
-
- {{ iface.outgoing_announce_frequency }}/s +
+
+ {{ $t("rnstatus.noise_floor") }} +
+
{{ iface.noise_floor }}
-
-
-
{{ $t("rnstatus.airtime") }}
-
- {{ iface.airtime.short }}% (15s), {{ iface.airtime.long }}% (1h) +
+
+ {{ $t("rnstatus.interference") }} +
+
{{ iface.interference }}
-
-
-
{{ $t("rnstatus.channel_load") }}
-
- {{ iface.channel_load.short }}% (15s), {{ iface.channel_load.long }}% (1h) +
+
+ {{ $t("rnstatus.cpu_load") }} +
+
{{ iface.cpu_load }}
+
+
+
+ {{ $t("rnstatus.cpu_temp") }} +
+
{{ iface.cpu_temp }}
+
+
+
+ {{ $t("rnstatus.memory_load") }} +
+
{{ iface.mem_load }}
+
+
+
{{ $t("rnstatus.battery") }}
+
+ {{ formatInt(iface.battery_percent) }}% + ({{ iface.battery_state }}) +
+
+
+
{{ $t("rnstatus.network") }}
+
{{ iface.network_name }}
+
+
+
+ {{ $t("rnstatus.incoming_announces") }} +
+
+ {{ iface.incoming_announce_frequency }}/s +
+
+
+
+ {{ $t("rnstatus.outgoing_announces") }} +
+
+ {{ iface.outgoing_announce_frequency }}/s +
+
+
+
{{ $t("rnstatus.airtime") }}
+
+ {{ iface.airtime.short }}% (15s), {{ iface.airtime.long }}% (1h) +
+
+
+
+ {{ $t("rnstatus.channel_load") }} +
+
+ {{ iface.channel_load.short }}% (15s), {{ iface.channel_load.long }}% (1h) +
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue index b68263b7..9b9da5ca 100644 --- a/meshchatx/src/frontend/components/settings/SettingsPage.vue +++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue @@ -3164,6 +3164,23 @@ : $t("app.landlock_inactive") }}
+
+ {{ $t("app.seccomp_status") }}: + {{ + serverSecurity.seccomp_active + ? serverSecurity.seccomp_auto_enabled + ? $t("app.seccomp_auto_enabled") + : $t("app.seccomp_active") + : serverSecurity.seccomp_kernel_supported === false + ? $t("app.seccomp_kernel_unsupported") + : serverSecurity.seccomp_disabled_by_env + ? $t("app.seccomp_disabled_by_env") + : $t("app.seccomp_inactive") + }} +
= 3 + lib.seccomp_load.assert_called_once_with(ctx) + lib.seccomp_release.assert_called_once_with(ctx) + + +@pytest.mark.skipif(sys.platform != "linux", reason="seccomp probe requires Linux") +def test_seccomp_kernel_supported_on_linux(): + supported = sc.seccomp_kernel_supported() + assert isinstance(supported, bool) diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js index 1f75845c..f7ef900a 100644 --- a/tests/frontend/ToolsPage.test.js +++ b/tests/frontend/ToolsPage.test.js @@ -83,6 +83,14 @@ describe("ToolsPage.vue", () => { expect(rnshRow?.text()).toContain("tools.alpha_badge"); }); + it("shows an alpha badge on the rns-filesync tool", () => { + const wrapper = mountToolsPage(); + const filesync = wrapper.vm.tools.find((tool) => tool.name === "rns-filesync"); + expect(filesync?.alpha).toBe(true); + const filesyncRow = wrapper.findAll(".tool-row").find((row) => row.text().includes("tools.rns_filesync.title")); + expect(filesyncRow?.text()).toContain("tools.alpha_badge"); + }); + it("clears search query when close button is clicked", async () => { const wrapper = mountToolsPage(); const searchInput = wrapper.find("input"); diff --git a/tests/frontend/filesyncPackagingContracts.test.js b/tests/frontend/filesyncPackagingContracts.test.js index 1e58c7ad..5cdece1d 100644 --- a/tests/frontend/filesyncPackagingContracts.test.js +++ b/tests/frontend/filesyncPackagingContracts.test.js @@ -28,7 +28,8 @@ describe("filesync packaging contracts", () => { const tools = readSource("meshchatx/src/frontend/js/registries/coreToolsEntries.js"); expect(tools).toContain('name: "rns-filesync"'); expect(tools).toContain('route: { name: "rns-filesync" }'); - const block = tools.slice(tools.indexOf('name: "rns-filesync"'), tools.indexOf('name: "debug-logs"')); + const block = tools.slice(tools.indexOf('name: "rns-filesync"'), tools.indexOf('name: "rnsh"')); expect(block).not.toContain("comingSoon"); + expect(block).toContain("alpha: true"); }); });