diff --git a/docs/en/nomad-network.md b/docs/en/nomad-network.md index 0c0cd821..2adcaf97 100644 --- a/docs/en/nomad-network.md +++ b/docs/en/nomad-network.md @@ -40,6 +40,18 @@ Typical workflow: 3. Start the node and announce it on the mesh. 4. Share your destination hash so others can open `/page/index.mu` on your node. +### Executable (dynamic) pages + +On Linux and macOS you can opt in per node to **executable pages**. When enabled: + +- Non-executable pages are served as static files. +- Pages marked executable (via the Mesh Server UI or `chmod +x`) run as scripts using their shebang. +- Request `field_*` and `var_*` values are passed as environment variables. +- `link_id` and `remote_identity` are supplied when available. +- Script stdout is returned as the page body. Failures return a controlled error page. + +Executable pages are disabled on Windows (static serving only). Editing a page in Mesh Server always shows the file source, never the script output. + API endpoints under `/api/v1/page-nodes/` manage CRUD operations, start and stop, and file listings. Pages are served at `/page/` and files at `/file/` on the node destination. diff --git a/meshchatx.rsm b/meshchatx.rsm index 7f20d54f..8e3606ad 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 1ee7ec6d..5f421547 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -10216,7 +10216,14 @@ class ReticulumMeshChat: self.config.nomad_default_page_path.get() or "/page/index.mu", ) - def _try_serve_local_page_node(self, destination_hash, page_path): + def _try_serve_local_page_node( + self, + destination_hash, + page_path, + request_data=None, + link_id=None, + remote_identity=None, + ): """Serve a page from disk when the hash matches a local page node. Returns the page content string, or None. @@ -10228,10 +10235,15 @@ class ReticulumMeshChat: page_name = page_path.lstrip("/") page_name = page_name.removeprefix("page/") page_name = os.path.basename(page_name) - content = node.get_page_content(page_name) - if content is not None: - node._stats["pages_served"] += 1 - return content + raw = node.serve_page_content( + page_name, + data=request_data, + link_id=link_id, + remote_identity=remote_identity, + ) + if raw is None: + return None + return raw.decode("utf-8", errors="replace") return None def _try_serve_local_page_node_file(self, destination_hash, file_path): diff --git a/meshchatx/src/backend/http/routes/page_nodes.py b/meshchatx/src/backend/http/routes/page_nodes.py index 5abddd5b..400e0ecf 100644 --- a/meshchatx/src/backend/http/routes/page_nodes.py +++ b/meshchatx/src/backend/http/routes/page_nodes.py @@ -148,10 +148,12 @@ def register_page_nodes_routes(routes, app): return web.json_response({"message": "Name is required"}, status=400) announce_enabled = bool(data.get("announce_enabled", True)) announce_interval_seconds = data.get("announce_interval_seconds") + executable_pages_enabled = bool(data.get("executable_pages_enabled", False)) node = app.page_node_manager.create_node( name, announce_enabled=announce_enabled, announce_interval_seconds=announce_interval_seconds, + executable_pages_enabled=executable_pages_enabled, ) return web.json_response(node.get_status()) @@ -240,12 +242,25 @@ def register_page_nodes_routes(routes, app): if "announce_interval_seconds" in data else None ) + executable_pages_enabled = ( + data.get("executable_pages_enabled") + if "executable_pages_enabled" in data + else None + ) try: + node = app.page_node_manager.get_node(node_id) + if node is None: + return web.json_response({"message": "Node not found"}, status=404) node = app.page_node_manager.set_announce_settings( node_id, announce_enabled=announce_enabled, announce_interval_seconds=announce_interval_seconds, ) + if executable_pages_enabled is not None: + node = app.page_node_manager.set_executable_pages_enabled( + node_id, + bool(executable_pages_enabled), + ) return web.json_response(node.get_status()) except KeyError: return web.json_response({"message": "Node not found"}, status=404) @@ -273,15 +288,21 @@ def register_page_nodes_routes(routes, app): ) name = data.get("name", "") content = data.get("content", "") + executable = data.get("executable") if "executable" in data else None if not name: return web.json_response( {"message": "Page name is required"}, status=400, ) try: - saved_name = node.add_page(name, content) + saved_name = node.add_page(name, content, executable=executable) except ValueError as e: return web.json_response({"message": str(e)}, status=400) + except OSError as e: + return web.json_response( + {"message": f"Failed to set executable flag: {e}"}, + status=400, + ) except OSError as e: return web.json_response( {"message": f"Failed to write page: {e}"}, @@ -292,7 +313,13 @@ def register_page_nodes_routes(routes, app): {"message": f"Failed to save page: {e}"}, status=500, ) - return web.json_response({"name": saved_name, "message": "Page saved"}) + return web.json_response( + { + "name": saved_name, + "executable": node.is_page_executable(saved_name), + "message": "Page saved", + }, + ) @routes.get("/api/v1/page-nodes/{node_id}/pages/{page_name}") async def page_nodes_get_page(request): @@ -304,7 +331,13 @@ def register_page_nodes_routes(routes, app): content = node.get_page_content(page_name) if content is None: return web.json_response({"message": "Page not found"}, status=404) - return web.json_response({"name": page_name, "content": content}) + return web.json_response( + { + "name": page_name, + "content": content, + "executable": node.is_page_executable(page_name), + }, + ) @routes.delete("/api/v1/page-nodes/{node_id}/pages/{page_name}") async def page_nodes_delete_page(request): diff --git a/meshchatx/src/backend/http/ws/handlers_nomad.py b/meshchatx/src/backend/http/ws/handlers_nomad.py index 42308111..2350925e 100644 --- a/meshchatx/src/backend/http/ws/handlers_nomad.py +++ b/meshchatx/src/backend/http/ws/handlers_nomad.py @@ -531,6 +531,7 @@ async def handle_nomadnet_page_download(app, client, data): local_page = app._try_serve_local_page_node( destination_hash, page_path_to_download, + request_data=combined_data, ) if local_page is not None: app.archive_page(destination_hash.hex(), page_path, local_page) diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py index b4632c39..4e7671b0 100644 --- a/meshchatx/src/backend/page_node.py +++ b/meshchatx/src/backend/page_node.py @@ -17,6 +17,8 @@ Supported page filename extensions are .mu, .md, .txt, and .html. import contextlib import json import os +import stat +import subprocess import threading import time @@ -31,6 +33,9 @@ ALLOWED_PAGE_EXTENSIONS = frozenset({".mu", ".md", ".txt", ".html"}) DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900 MIN_ANNOUNCE_INTERVAL_SECONDS = 60 MAX_ANNOUNCE_INTERVAL_SECONDS = 86400 +EXECUTABLE_PAGE_TIMEOUT_SECONDS = 15 + +PAGE_GENERATION_FAILED_MICRON = ">Page Generation Failed\n\nThe page could not be generated.\n" def normalize_announce_interval_seconds( @@ -83,6 +88,47 @@ def _safe_mesh_file_basename(name: str) -> str: return base +def _is_windows_platform() -> bool: + if os.name == "nt": + return True + try: + return bool(RNS.vendor.platformutils.is_windows()) + except Exception: + return False + + +def _page_generation_error_bytes(reason: str) -> bytes: + text = PAGE_GENERATION_FAILED_MICRON + if reason: + text += f"\n{reason}\n" + return text.encode("utf-8") + + +def _build_executable_page_env( + data, + link_id=None, + remote_identity=None, +) -> dict[str, str]: + env_map: dict[str, str] = {} + if "PATH" in os.environ: + env_map["PATH"] = os.environ["PATH"] + if link_id is not None: + with contextlib.suppress(Exception): + env_map["link_id"] = RNS.hexrep(link_id, delimit=False) + if remote_identity is not None: + with contextlib.suppress(Exception): + remote_hash = getattr(remote_identity, "hash", None) + if remote_hash is not None: + env_map["remote_identity"] = RNS.hexrep(remote_hash, delimit=False) + if data is not None and isinstance(data, dict): + for key, value in data.items(): + if isinstance(key, str) and ( + key.startswith("field_") or key.startswith("var_") + ): + env_map[key] = str(value) + return env_map + + def _reject_name_component_too_long(parent_dir: str, component: str) -> None: """Raise ValueError if basename exceeds this directory's filename length limit.""" try: @@ -108,6 +154,7 @@ class PageNode: identity_path=None, announce_enabled=True, announce_interval_seconds=DEFAULT_ANNOUNCE_INTERVAL_SECONDS, + executable_pages_enabled=False, on_announce=None, ): self.node_id = node_id @@ -131,6 +178,7 @@ class PageNode: self.announce_interval_seconds = normalize_announce_interval_seconds( announce_interval_seconds, ) + self.executable_pages_enabled = bool(executable_pages_enabled) self.last_announced_at = None self.on_announce = on_announce self._announce_timer = None @@ -227,6 +275,10 @@ class PageNode: ) self._sync_announce_timer() + def set_executable_pages_enabled(self, enabled): + """Enable or disable dynamic executable page serving for this node.""" + self.executable_pages_enabled = bool(enabled) + def teardown(self): """Deregister handlers and clean up.""" self.running = False @@ -363,22 +415,108 @@ class PageNode: self.destination.deregister_request_handler(rpath) self._registered_file_paths.discard(rpath) + def _resolve_page_path(self, name): + try: + safe_name = normalize_page_filename(name) + _reject_name_component_too_long(self.pages_dir, safe_name) + except ValueError: + return None + page_path = os.path.join(self.pages_dir, safe_name) + if not os.path.isfile(page_path): + return None + return page_path + + def is_page_executable(self, name): + """Return whether the page file has the execute permission bit set.""" + if _is_windows_platform(): + return False + page_path = self._resolve_page_path(name) + if page_path is None: + return False + return os.access(page_path, os.X_OK) + + def set_page_executable(self, name, enabled): + """Set or clear the execute permission bit on a page file.""" + if _is_windows_platform(): + raise OSError("executable pages are not supported on Windows") + page_path = self._resolve_page_path(name) + if page_path is None: + raise ValueError("page not found") + mode = os.stat(page_path).st_mode + if enabled: + os.chmod(page_path, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + else: + os.chmod( + page_path, + mode & ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH), + ) + + def _should_execute_page(self, page_path): + if not self.executable_pages_enabled: + return False + if _is_windows_platform(): + return False + return os.access(page_path, os.X_OK) + + def _read_static_page_bytes(self, page_path): + with open(page_path, "rb") as f: + return f.read() + + def _execute_page_bytes(self, page_path, data=None, link_id=None, remote_identity=None): + env_map = _build_executable_page_env(data, link_id, remote_identity) + try: + generated = subprocess.run( + [page_path], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env_map, + timeout=EXECUTABLE_PAGE_TIMEOUT_SECONDS, + ) + return generated.stdout + except subprocess.TimeoutExpired: + return _page_generation_error_bytes("The page script timed out.") + except OSError as e: + return _page_generation_error_bytes(str(e)) + except Exception as e: + return _page_generation_error_bytes(str(e)) + + def serve_page_content( + self, + name, + data=None, + link_id=None, + remote_identity=None, + ): + """Serve a page as static bytes or by executing it when enabled and executable.""" + page_path = self._resolve_page_path(name) + if page_path is None: + return None + try: + if self._should_execute_page(page_path): + content = self._execute_page_bytes( + page_path, + data=data, + link_id=link_id, + remote_identity=remote_identity, + ) + else: + content = self._read_static_page_bytes(page_path) + self._stats["pages_served"] += 1 + return content + except Exception: + return None + def _make_page_responder(self, page_name): """Return a closure that serves a specific page.""" def responder(path, data, request_id, link_id, remote_identity, requested_at): self._note_remote_identity(remote_identity) - safe_name = os.path.basename(page_name) - page_path = os.path.join(self.pages_dir, safe_name) - if not os.path.isfile(page_path): - return None - try: - with open(page_path, "rb") as f: - content = f.read() - self._stats["pages_served"] += 1 - return content - except Exception: - return None + return self.serve_page_content( + page_name, + data=data, + link_id=link_id, + remote_identity=remote_identity, + ) return responder @@ -401,7 +539,7 @@ class PageNode: return responder - def add_page(self, name, content): + def add_page(self, name, content, executable=None): """Write a page file and register its request handler.""" os.makedirs(self.pages_dir, exist_ok=True) name = normalize_page_filename(name) @@ -411,6 +549,8 @@ class PageNode: content = content.encode("utf-8") with open(page_path, "wb") as f: f.write(content) + if executable is not None: + self.set_page_executable(name, bool(executable)) if self.running: try: self._register_page_handler(name) @@ -435,15 +575,21 @@ class PageNode: return False def list_pages(self): - """Return a list of page names.""" + """Return page metadata dicts with name and executable state.""" if not os.path.isdir(self.pages_dir): return [] - return sorted( - f - for f in os.listdir(self.pages_dir) - if os.path.isfile(os.path.join(self.pages_dir, f)) - and is_allowed_page_filename(f) - ) + pages = [] + for fname in sorted(os.listdir(self.pages_dir)): + fpath = os.path.join(self.pages_dir, fname) + if not os.path.isfile(fpath) or not is_allowed_page_filename(fname): + continue + pages.append( + { + "name": fname, + "executable": self.is_page_executable(fname), + }, + ) + return pages def get_page_content(self, name): """Read and return a page's content.""" @@ -524,6 +670,7 @@ class PageNode: "stats": dict(self._stats), "announce_enabled": self.announce_enabled, "announce_interval_seconds": self.announce_interval_seconds, + "executable_pages_enabled": self.executable_pages_enabled, "last_announced_at": self.last_announced_at, } @@ -534,6 +681,7 @@ class PageNode: "name": self.name, "announce_enabled": self.announce_enabled, "announce_interval_seconds": self.announce_interval_seconds, + "executable_pages_enabled": self.executable_pages_enabled, } config_path = os.path.join(self.base_dir, "config.json") with open(config_path, "w") as f: diff --git a/meshchatx/src/backend/page_node_manager.py b/meshchatx/src/backend/page_node_manager.py index d01c6d14..13c80cfa 100644 --- a/meshchatx/src/backend/page_node_manager.py +++ b/meshchatx/src/backend/page_node_manager.py @@ -48,6 +48,7 @@ class PageNodeManager: announce_interval_seconds=normalize_announce_interval_seconds( config.get("announce_interval_seconds"), ), + executable_pages_enabled=config.get("executable_pages_enabled", False), on_announce=self.on_announce, ) self.nodes[node_id] = node @@ -58,6 +59,7 @@ class PageNodeManager: node_id=None, announce_enabled=True, announce_interval_seconds=None, + executable_pages_enabled=False, ): """Create a new page node, persist its config, and return it.""" if node_id is None: @@ -74,6 +76,7 @@ class PageNodeManager: announce_interval_seconds=normalize_announce_interval_seconds( announce_interval_seconds, ), + executable_pages_enabled=executable_pages_enabled, on_announce=self.on_announce, ) node.save_config() @@ -158,6 +161,15 @@ class PageNodeManager: node.save_config() return node + def set_executable_pages_enabled(self, node_id, enabled): + """Enable or disable executable page serving for a node.""" + node = self.nodes.get(node_id) + if node is None: + raise KeyError(f"Node {node_id} not found") + node.set_executable_pages_enabled(enabled) + node.save_config() + return node + def rename_node(self, node_id, new_name): """Rename a node and persist the change.""" node = self.nodes.get(node_id) diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue index 90e3ab60..00658a14 100644 --- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue +++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue @@ -185,6 +185,10 @@ import ToolsPageHeader from "../tools/ToolsPageHeader.vue"; const NOMAD_DESTINATION_HASH = /^[a-fA-F0-9]{32}$/; const PAGE_EXTENSIONS = [".mu", ".html", ".md", ".txt"]; +function pageNamesFromList(pages) { + return (pages || []).map((entry) => (typeof entry === "string" ? entry : entry?.name)).filter(Boolean); +} + export default { name: "MicronEditorPage", components: { @@ -1052,7 +1056,8 @@ ${b}= return /^\d+$/.test(trimmed.slice(numberedPrefix.length)); }, async resolvePublishPageBase(tab, existingPages, serverName) { - const hasIndex = existingPages.includes("index.mu"); + const pageNames = pageNamesFromList(existingPages); + const hasIndex = pageNames.includes("index.mu"); if (!hasIndex) { return "index"; } @@ -1127,7 +1132,8 @@ ${b}= }); const savedName = response.data?.name; if (savedName) { - existingPages = [...new Set([...existingPages, savedName])]; + const pageNames = pageNamesFromList(existingPages); + existingPages = [...new Set([...pageNames, savedName])]; } published++; } catch { diff --git a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue index 68cc1cf1..a619c1c2 100644 --- a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue +++ b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue @@ -205,6 +205,16 @@
{{ $t("tools.mesh_server.announce_settings") }}
+
+ +
+
+ {{ $t("tools.mesh_server.executable_pages_warning") }} +
- {{ page }} + {{ page.name }} + + {{ $t("tools.mesh_server.executable_badge") }} +
- @@ -338,6 +354,13 @@
+
+ +