| | """Node MCP tool implementations and lightweight HTTP server.""" |
| |
|
| | from __future__ import annotations |
| |
|
| | import json |
| | import logging |
| | import os |
| | import platform |
| | import shlex |
| | import shutil |
| | import subprocess |
| | import time |
| | from dataclasses import dataclass |
| | from http import HTTPStatus |
| | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| | from pathlib import Path |
| | from typing import Any, Callable, Dict |
| |
|
| | import psutil |
| | from pydantic import BaseModel, Field, ValidationError |
| |
|
| | from .config import NodeConfig |
| | from .filesystem import hash_file, list_files |
| |
|
| | logger = logging.getLogger(__name__) |
| |
|
| | ToolFunc = Callable[[NodeConfig, dict[str, Any]], dict[str, Any]] |
| |
|
| |
|
| | def _resolve_within_root(root: Path, requested: str) -> Path: |
| | candidate = Path(requested) |
| | candidate = candidate.expanduser() |
| | if not candidate.is_absolute(): |
| | candidate = (root / candidate).resolve() |
| | else: |
| | candidate = candidate.resolve() |
| |
|
| | try: |
| | candidate.relative_to(root) |
| | except ValueError as exc: |
| | raise PermissionError(f"Requested path escapes root: {candidate}") from exc |
| | return candidate |
| |
|
| |
|
| | class ListFilesRequest(BaseModel): |
| | path: str = "." |
| | recursive: bool = False |
| | pattern: str | None = None |
| | include_hash: bool = False |
| | limit: int | None = Field(default=None, ge=1, le=20_000) |
| |
|
| |
|
| | class ReadFileRequest(BaseModel): |
| | path: str |
| | encoding: str | None = "utf-8" |
| | max_bytes: int | None = Field(default=None, ge=1, le=50_000_000) |
| |
|
| |
|
| | class WriteFileRequest(BaseModel): |
| | path: str |
| | content: str |
| | encoding: str = "utf-8" |
| | overwrite: bool = False |
| | create_dirs: bool = True |
| | backup: bool = True |
| |
|
| |
|
| | class ExecuteCommandRequest(BaseModel): |
| | command: list[str] | str |
| | timeout: float = Field(default=60.0, gt=0, le=600) |
| | env: dict[str, str] = Field(default_factory=dict) |
| | cwd: str | None = None |
| |
|
| |
|
| | class SyncFilesRequest(BaseModel): |
| | source_path: str |
| | targets: list[str] = Field(..., min_length=1) |
| | strategy: str = Field(default="mirror", pattern=r"^(mirror|append)$") |
| |
|
| |
|
| | class GetNodeInfoRequest(BaseModel): |
| | include_processes: bool = False |
| |
|
| |
|
| | @dataclass(slots=True) |
| | class SyncReport: |
| | target: str |
| | dest_path: str |
| | files_synced: int |
| | bytes_copied: int |
| | duration: float |
| |
|
| | def to_dict(self) -> dict[str, Any]: |
| | return { |
| | "target": self.target, |
| | "dest_path": self.dest_path, |
| | "files_synced": self.files_synced, |
| | "bytes_copied": self.bytes_copied, |
| | "duration": self.duration, |
| | } |
| |
|
| |
|
| | def list_files_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
| | request = ListFilesRequest.model_validate(payload) |
| | target = _resolve_within_root(config.root_dir, request.path) |
| | files = list_files( |
| | target, |
| | recursive=request.recursive, |
| | pattern=request.pattern, |
| | include_hash=request.include_hash, |
| | root=config.root_dir, |
| | ) |
| | if request.limit is not None: |
| | files = files[: request.limit] |
| | return {"files": [file.to_dict() for file in files], "count": len(files)} |
| |
|
| |
|
| | def read_file_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
| | request = ReadFileRequest.model_validate(payload) |
| | target = _resolve_within_root(config.root_dir, request.path) |
| | if not target.exists(): |
| | raise FileNotFoundError(str(target)) |
| | if target.is_dir(): |
| | raise IsADirectoryError(str(target)) |
| |
|
| | data = target.read_bytes() |
| | if request.max_bytes and len(data) > request.max_bytes: |
| | raise ValueError("File exceeds max_bytes limit") |
| |
|
| | content: str | None = None |
| | if request.encoding: |
| | content = data.decode(request.encoding) |
| | return { |
| | "path": str(target.relative_to(config.root_dir)), |
| | "size": len(data), |
| | "hash": hash_file(target), |
| | "content": content, |
| | } |
| |
|
| |
|
| | def write_file_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
| | request = WriteFileRequest.model_validate(payload) |
| | target = _resolve_within_root(config.root_dir, request.path) |
| | target.parent.mkdir(parents=True, exist_ok=request.create_dirs) |
| |
|
| | backup_path: Path | None = None |
| | if target.exists(): |
| | if not request.overwrite: |
| | raise FileExistsError(str(target)) |
| | if request.backup: |
| | timestamp = int(time.time()) |
| | backup_path = target.parent / f"{target.name}.bak.{timestamp}" |
| | shutil.copy2(target, backup_path) |
| |
|
| | data = request.content.encode(request.encoding) |
| | target.write_bytes(data) |
| | file_hash = hash_file(target) |
| | return { |
| | "success": True, |
| | "path": str(target.relative_to(config.root_dir)), |
| | "bytes_written": len(data), |
| | "hash": file_hash, |
| | "backup_path": str(backup_path) if backup_path else None, |
| | "message": f"File written successfully: {target.relative_to(config.root_dir)}" |
| | } |
| |
|
| |
|
| | def execute_command_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
| | request = ExecuteCommandRequest.model_validate(payload) |
| | if isinstance(request.command, str): |
| | command = shlex.split(request.command) |
| | else: |
| | command = request.command |
| |
|
| | if not command: |
| | raise ValueError("Command cannot be empty") |
| |
|
| | base_cmd = Path(command[0]).name |
| | if base_cmd not in config.allowed_commands: |
| | raise PermissionError(f"Command '{base_cmd}' not on allow list") |
| |
|
| | cwd = _resolve_within_root(config.root_dir, request.cwd) if request.cwd else config.root_dir |
| |
|
| | env = os.environ.copy() |
| | env.update({key: value for key, value in request.env.items() if isinstance(value, str)}) |
| |
|
| | started = time.time() |
| | proc = subprocess.run( |
| | command, |
| | cwd=str(cwd), |
| | env=env, |
| | capture_output=True, |
| | text=True, |
| | timeout=request.timeout, |
| | ) |
| | duration = time.time() - started |
| | return { |
| | "command": command, |
| | "stdout": proc.stdout, |
| | "stderr": proc.stderr, |
| | "exit_code": proc.returncode, |
| | "duration": duration, |
| | "cwd": str(cwd.relative_to(config.root_dir)), |
| | } |
| |
|
| |
|
| | def sync_files_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
| | request = SyncFilesRequest.model_validate(payload) |
| | source = _resolve_within_root(config.root_dir, request.source_path) |
| | if not source.exists(): |
| | raise FileNotFoundError(str(source)) |
| |
|
| | reports: list[SyncReport] = [] |
| | source_rel = source.relative_to(config.root_dir) |
| |
|
| | for target_name in request.targets: |
| | if target_name not in config.sync_targets: |
| | raise ValueError(f"Unknown sync target: {target_name}") |
| | target_dir = config.sync_targets[target_name] |
| | dest_root = target_dir / source_rel |
| | dest_root.parent.mkdir(parents=True, exist_ok=True) |
| |
|
| | started = time.time() |
| | files_synced, bytes_copied = _copy_path(source, dest_root, strategy=request.strategy) |
| | duration = time.time() - started |
| | reports.append( |
| | SyncReport( |
| | target=target_name, |
| | dest_path=str(dest_root), |
| | files_synced=files_synced, |
| | bytes_copied=bytes_copied, |
| | duration=duration, |
| | ) |
| | ) |
| |
|
| | return { |
| | "source": str(source_rel), |
| | "targets": [report.to_dict() for report in reports], |
| | } |
| |
|
| |
|
| | def _copy_path(source: Path, dest: Path, *, strategy: str) -> tuple[int, int]: |
| | files_synced = 0 |
| | bytes_copied = 0 |
| |
|
| | if source.is_file(): |
| | dest.parent.mkdir(parents=True, exist_ok=True) |
| | shutil.copy2(source, dest) |
| | files_synced = 1 |
| | bytes_copied = source.stat().st_size |
| | return files_synced, bytes_copied |
| |
|
| | if strategy == "mirror" and dest.exists(): |
| | shutil.rmtree(dest) |
| |
|
| | for src_file in source.rglob("*"): |
| | if not src_file.is_file(): |
| | continue |
| | rel = src_file.relative_to(source) |
| | dest_file = dest / rel |
| | dest_file.parent.mkdir(parents=True, exist_ok=True) |
| | shutil.copy2(src_file, dest_file) |
| | files_synced += 1 |
| | bytes_copied += src_file.stat().st_size |
| |
|
| | return files_synced, bytes_copied |
| |
|
| |
|
| | def get_node_info_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
| | _ = GetNodeInfoRequest.model_validate(payload or {}) |
| | cpu = psutil.cpu_percent(interval=0.05) |
| | mem = psutil.virtual_memory() |
| | disk = psutil.disk_usage(str(config.root_dir)) |
| | boot_time = psutil.boot_time() |
| |
|
| | return { |
| | "node_id": config.node_id, |
| | "tags": config.tags, |
| | "description": config.description, |
| | "root_dir": str(config.root_dir), |
| | "allowed_commands": config.allowed_commands, |
| | "sync_targets": {key: str(value) for key, value in config.sync_targets.items()}, |
| | "metrics": { |
| | "cpu_percent": cpu, |
| | "memory_percent": mem.percent, |
| | "memory_total": mem.total, |
| | "disk_percent": disk.percent, |
| | "disk_total": disk.total, |
| | "uptime_seconds": time.time() - boot_time, |
| | }, |
| | "platform": { |
| | "system": platform.system(), |
| | "release": platform.release(), |
| | "version": platform.version(), |
| | "machine": platform.machine(), |
| | "python_version": platform.python_version(), |
| | }, |
| | "timestamp": time.time(), |
| | } |
| |
|
| |
|
| | class NodeServer: |
| | """Minimal HTTP server that exposes node tools as JSON endpoints.""" |
| |
|
| | def __init__(self, config: NodeConfig, *, host: str = "0.0.0.0", port: int = 8765): |
| | self.config = config |
| | self.host = host |
| | self.port = port |
| | self._httpd: ThreadingHTTPServer | None = None |
| |
|
| | def serve_forever(self) -> None: |
| | handler = self._build_handler() |
| | self._httpd = ThreadingHTTPServer((self.host, self.port), handler) |
| | logger.info("[nacc-node] serving http://%s:%s", self.host, self.port) |
| | try: |
| | self._httpd.serve_forever() |
| | finally: |
| | self._httpd.server_close() |
| | logger.info("[nacc-node] server stopped") |
| |
|
| | def shutdown(self) -> None: |
| | if self._httpd: |
| | self._httpd.shutdown() |
| |
|
| | def _build_handler(self) -> type[BaseHTTPRequestHandler]: |
| | config = self.config |
| | tools: Dict[str, ToolFunc] = { |
| | "list-files": list_files_tool, |
| | "read-file": read_file_tool, |
| | "write-file": write_file_tool, |
| | "execute-command": execute_command_tool, |
| | "sync-files": sync_files_tool, |
| | "get-node-info": get_node_info_tool, |
| | } |
| | max_body = 512 * 1024 |
| |
|
| | class NodeRequestHandler(BaseHTTPRequestHandler): |
| | server_version = "NACCNode/0.3" |
| |
|
| | def log_message(self, format: str, *args: Any) -> None: |
| | logger.info("%s - %s", self.address_string(), format % args) |
| |
|
| | def _read_json_body(self) -> dict[str, Any]: |
| | content_length = int(self.headers.get("Content-Length", 0)) |
| | if content_length > max_body: |
| | raise ValueError("Payload too large") |
| | if content_length <= 0: |
| | return {} |
| | body = self.rfile.read(content_length) |
| | if not body: |
| | return {} |
| | return json.loads(body.decode("utf-8")) |
| |
|
| | def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: |
| | data = json.dumps(payload).encode("utf-8") |
| | self.send_response(status) |
| | self.send_header("Content-Type", "application/json") |
| | self.send_header("Content-Length", str(len(data))) |
| | self.end_headers() |
| | self.wfile.write(data) |
| |
|
| | def do_GET(self) -> None: |
| | logger.info(f"GET request to: {self.path}") |
| | |
| | |
| | path_clean = self.path.split('?')[0] |
| | |
| | if path_clean == "/healthz": |
| | self._send_json(HTTPStatus.OK, { |
| | "status": "ok", |
| | "service": "nacc-node", |
| | "node_id": config.node_id |
| | }) |
| | return |
| | if path_clean == "/node": |
| | payload = get_node_info_tool(config, {}) |
| | self._send_json(HTTPStatus.OK, payload) |
| | return |
| | |
| | if path_clean == "/" or path_clean == "/index.html" or path_clean == "/dashboard": |
| | |
| | html = """ |
| | <!DOCTYPE html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="UTF-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | <title>NACC VM Node</title> |
| | <style> |
| | body { font-family: 'Courier New', Courier, monospace; background: #0d1117; color: #c9d1d9; margin: 0; padding: 20px; } |
| | .container { max-width: 1000px; margin: 0 auto; } |
| | h1 { border-bottom: 1px solid #30363d; padding-bottom: 10px; color: #58a6ff; font-family: -apple-system, sans-serif; } |
| | .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 20px; } |
| | .card h2 { margin-top: 0; font-size: 1.2em; color: #79c0ff; font-family: -apple-system, sans-serif; } |
| | |
| | /* Terminal Styles */ |
| | .terminal { background: #010409; padding: 15px; border-radius: 6px; border: 1px solid #30363d; height: 400px; overflow-y: auto; display: flex; flex-direction: column; } |
| | .output { flex-grow: 1; white-space: pre-wrap; word-break: break-all; } |
| | .input-line { display: flex; align-items: center; margin-top: 10px; border-top: 1px solid #21262d; padding-top: 10px; } |
| | .prompt { color: #3fb950; margin-right: 10px; font-weight: bold; } |
| | input { background: transparent; border: none; color: #c9d1d9; flex-grow: 1; font-family: inherit; font-size: 1em; outline: none; } |
| | |
| | .status-ok { color: #3fb950; font-weight: bold; } |
| | table { width: 100%; border-collapse: collapse; font-family: -apple-system, sans-serif; } |
| | th, td { text-align: left; padding: 8px; border-bottom: 1px solid #21262d; } |
| | th { color: #8b949e; } |
| | tr:hover { background: #21262d; } |
| | </style> |
| | </head> |
| | <body> |
| | <div class="container"> |
| | <h1>🖥️ NACC Virtual Machine Node</h1> |
| | |
| | <div class="card"> |
| | <h2>Status: <span class="status-ok">RUNNING</span></h2> |
| | <div id="node-info" style="font-family: -apple-system, sans-serif;">Loading system info...</div> |
| | </div> |
| | |
| | <div class="card"> |
| | <h2>💻 Secure Terminal</h2> |
| | <div class="terminal" id="terminal" onclick="document.getElementById('cmd-input').focus()"> |
| | <div class="output" id="output"> |
| | Welcome to NACC VM Secure Terminal. |
| | Allowed commands: ls, cat, pwd, echo, grep, find, head, tail, tree, whoami, id |
| | Type 'help' for info. |
| | </div> |
| | <div class="input-line"> |
| | <span class="prompt" id="prompt">user@vm:~$</span> |
| | <input type="text" id="cmd-input" autocomplete="off" spellcheck="false"> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | <div class="card"> |
| | <h2>📂 File Browser</h2> |
| | <button onclick="listFiles()" style="background: #238636; color: white; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer;">Refresh Current Dir</button> |
| | <div id="file-list" style="margin-top: 10px;"></div> |
| | </div> |
| | </div> |
| | |
| | <script> |
| | let currentDir = "."; |
| | const input = document.getElementById('cmd-input'); |
| | const output = document.getElementById('output'); |
| | const prompt = document.getElementById('prompt'); |
| | |
| | input.addEventListener('keydown', async (e) => { |
| | if (e.key === 'Enter') { |
| | const cmd = input.value.trim(); |
| | input.value = ''; |
| | if (!cmd) return; |
| | |
| | appendToOutput(prompt.innerText + ' ' + cmd); |
| | await processCommand(cmd); |
| | // Keep focus |
| | input.focus(); |
| | // Scroll to bottom |
| | document.getElementById('terminal').scrollTop = document.getElementById('terminal').scrollHeight; |
| | } |
| | }); |
| | |
| | function appendToOutput(text) { |
| | const div = document.createElement('div'); |
| | div.innerText = text; |
| | output.appendChild(div); |
| | } |
| | |
| | async function processCommand(cmd) { |
| | const args = cmd.split(' '); |
| | const baseCmd = args[0]; |
| | |
| | if (baseCmd === 'clear') { |
| | output.innerHTML = ''; |
| | return; |
| | } |
| | |
| | if (baseCmd === 'help') { |
| | appendToOutput("Available commands: ls, cat, pwd, echo, grep, find, head, tail, tree, whoami, id\\nNavigation: cd <path>"); |
| | return; |
| | } |
| | |
| | if (baseCmd === 'cd') { |
| | const target = args[1] || '.'; |
| | // Optimistic update, verify with pwd/ls later if needed |
| | // Simple path joining logic for display |
| | if (target === '..') { |
| | // Very basic parent handling |
| | const parts = currentDir.split('/'); |
| | parts.pop(); |
| | currentDir = parts.join('/') || '.'; |
| | } else if (target.startsWith('/')) { |
| | currentDir = target; |
| | } else { |
| | currentDir = (currentDir === '.' ? '' : currentDir + '/') + target; |
| | } |
| | updatePrompt(); |
| | // Verify path by running ls |
| | try { |
| | await execute('ls', currentDir); |
| | } catch (e) { |
| | appendToOutput("Error: Directory not found (or access denied)"); |
| | // Revert? Nah, let user fix it |
| | } |
| | listFiles(); // Update file browser too |
| | return; |
| | } |
| | |
| | // Execute on server |
| | await execute(cmd, currentDir); |
| | } |
| | |
| | async function execute(command, cwd) { |
| | try { |
| | const res = await fetch('/tools/execute-command', { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify({command: command, cwd: cwd}) |
| | }); |
| | const data = await res.json(); |
| | |
| | if (data.error) { |
| | appendToOutput("Error: " + data.error); |
| | } else { |
| | if (data.stdout) appendToOutput(data.stdout); |
| | if (data.stderr) appendToOutput("Stderr: " + data.stderr); |
| | if (data.exit_code !== 0) appendToOutput("[Exit: " + data.exit_code + "]"); |
| | |
| | // Update cwd from server response if available (it returns the resolved cwd) |
| | if (data.cwd) { |
| | // currentDir = data.cwd; // Optional: sync with server truth |
| | // updatePrompt(); |
| | } |
| | } |
| | } catch (e) { |
| | appendToOutput("Network Error: " + e.message); |
| | } |
| | } |
| | |
| | function updatePrompt() { |
| | prompt.innerText = `user@vm:${currentDir}$`; |
| | } |
| | |
| | async function fetchNodeInfo() { |
| | try { |
| | const res = await fetch('/node'); |
| | const data = await res.json(); |
| | document.getElementById('node-info').innerHTML = ` |
| | <p><strong>Node ID:</strong> ${data.node_id}</p> |
| | <p><strong>OS:</strong> ${data.platform.system} ${data.platform.release}</p> |
| | <p><strong>Root:</strong> ${data.root_dir}</p> |
| | `; |
| | } catch (e) {} |
| | } |
| | |
| | async function listFiles() { |
| | try { |
| | const res = await fetch('/tools/list-files', { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify({path: currentDir, recursive: false}) |
| | }); |
| | const data = await res.json(); |
| | if (data.files) { |
| | let html = '<table><tr><th>Name</th><th>Type</th><th>Size</th></tr>'; |
| | data.files.forEach(f => { |
| | html += `<tr><td>${f.name}</td><td>${f.is_dir ? 'DIR' : 'FILE'}</td><td>${f.size || '-'}</td></tr>`; |
| | }); |
| | html += '</table>'; |
| | document.getElementById('file-list').innerHTML = html; |
| | } else { |
| | document.getElementById('file-list').innerText = "Error listing files: " + JSON.stringify(data); |
| | } |
| | } catch (e) { |
| | document.getElementById('file-list').innerText = 'Error: ' + e.message; |
| | } |
| | } |
| | |
| | // Init |
| | fetchNodeInfo(); |
| | listFiles(); |
| | </script> |
| | </body> |
| | </html> |
| | """ |
| | self.send_response(HTTPStatus.OK) |
| | self.send_header("Content-Type", "text/html") |
| | self.send_header("Content-Length", str(len(html))) |
| | self.end_headers() |
| | self.wfile.write(html.encode("utf-8")) |
| | return |
| |
|
| | self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not Found"}) |
| |
|
| | def do_POST(self) -> None: |
| | if not self.path.startswith("/tools/"): |
| | self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown endpoint"}) |
| | return |
| | tool_name = self.path.split("/", 2)[-1] |
| | tool = tools.get(tool_name) |
| | if not tool: |
| | self._send_json(HTTPStatus.NOT_FOUND, {"error": f"Tool '{tool_name}' not available"}) |
| | return |
| |
|
| | try: |
| | payload = self._read_json_body() |
| | result = tool(config, payload) |
| | except json.JSONDecodeError as exc: |
| | self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON", "details": str(exc)}) |
| | return |
| | except ValidationError as exc: |
| | self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Validation failed", "details": exc.errors()}) |
| | return |
| | except PermissionError as exc: |
| | self._send_json(HTTPStatus.FORBIDDEN, {"error": str(exc)}) |
| | return |
| | except FileNotFoundError as exc: |
| | self._send_json(HTTPStatus.NOT_FOUND, {"error": str(exc)}) |
| | return |
| | except Exception as exc: |
| | logger.exception("Tool '%s' crashed", tool_name) |
| | self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)}) |
| | return |
| |
|
| | self._send_json(HTTPStatus.OK, result) |
| |
|
| | return NodeRequestHandler |
| |
|
| |
|
| | __all__ = [ |
| | "NodeServer", |
| | "list_files_tool", |
| | "read_file_tool", |
| | "write_file_tool", |
| | "execute_command_tool", |
| | "sync_files_tool", |
| | "get_node_info_tool", |
| | ] |
| |
|