fix(mcp): harden remote service boundaries
Harden the HTTP ingest upload path by validating host/origin and method before ticket consumption, enforcing request body limits, and returning 413 for oversized uploads. Hide local runtime/log paths from default remote responses while preserving internal paths for local OpenCode prompts. Add conservative runtime cleanup for tickets, uploads, jobs, and logs, plus MemoryMax on the managed systemd unit. Validation: python3 -m unittest tests/test_mcp_server_remote.py tests/test_mcp_ingest.py tests/test_mcp_query.py tests/test_mcp_service.py -v; python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check. Restarted obsidian-wiki-mcp.service and confirmed ActiveState=active, SubState=running, MemoryMax=2147483648, Restart=on-failure.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shlex
|
||||
@@ -11,6 +12,7 @@ import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
@@ -36,11 +38,14 @@ DEFAULT_INGEST_OPENCODE_MODEL = "openai/gpt-5.5"
|
||||
DEFAULT_INGEST_OPENCODE_VARIANT = "high"
|
||||
DEFAULT_QUERY_OPENCODE_MODEL = "openai/gpt-5.5"
|
||||
DEFAULT_QUERY_OPENCODE_VARIANT = "minimal"
|
||||
DEFAULT_SERVICE_MEMORY_MAX = "2G"
|
||||
DEFAULT_RUNTIME_CLEANUP_DAYS = 30
|
||||
TOOL_FILE_ASSETS = "file-assets"
|
||||
TOOL_INGEST = "ingest"
|
||||
TOOL_QUERY = "query"
|
||||
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY)
|
||||
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
|
||||
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
||||
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
|
||||
|
||||
|
||||
@@ -181,6 +186,7 @@ def render_service_unit(
|
||||
path: str = DEFAULT_PATH,
|
||||
uv_cache_dir: str = DEFAULT_UV_CACHE_DIR,
|
||||
uv_path: Path | None = None,
|
||||
memory_max: str = DEFAULT_SERVICE_MEMORY_MAX,
|
||||
) -> str:
|
||||
resolved_uv_path = uv_path or find_uv_path()
|
||||
command = " ".join(
|
||||
@@ -218,6 +224,7 @@ def render_service_unit(
|
||||
f"ExecStart={command}\n"
|
||||
"Restart=on-failure\n"
|
||||
"RestartSec=5\n"
|
||||
f"MemoryMax={memory_max}\n"
|
||||
"\n"
|
||||
"[Install]\n"
|
||||
"WantedBy=default.target\n"
|
||||
@@ -677,6 +684,132 @@ def service_status() -> None:
|
||||
run_systemctl(["status", SERVICE_NAME], check=False)
|
||||
|
||||
|
||||
def cleanup_runtime(project_root: Path, *, max_age_days: int = DEFAULT_RUNTIME_CLEANUP_DAYS) -> str:
|
||||
if max_age_days <= 0:
|
||||
raise RuntimeError("max_age_days must be positive")
|
||||
root = project_root.expanduser().resolve()
|
||||
cutoff = time.time() - (max_age_days * 24 * 60 * 60)
|
||||
ingest_root = root / ".runtime" / "mcp-ingest"
|
||||
query_root = root / ".runtime" / "mcp-query"
|
||||
active_upload_ids = _active_ingest_upload_ids(ingest_root / "jobs")
|
||||
result = {
|
||||
"expired_upload_tickets_deleted": _delete_expired_upload_tickets(ingest_root / "upload-tickets"),
|
||||
"old_ingest_uploads_deleted": _delete_old_ingest_uploads(
|
||||
ingest_root / "uploads",
|
||||
cutoff=cutoff,
|
||||
active_upload_ids=active_upload_ids,
|
||||
),
|
||||
"old_ingest_jobs_deleted": _delete_old_terminal_jobs(ingest_root / "jobs", cutoff=cutoff),
|
||||
"old_query_jobs_deleted": _delete_old_terminal_jobs(query_root / "jobs", cutoff=cutoff),
|
||||
}
|
||||
return "\n".join(
|
||||
[
|
||||
"MCP runtime cleanup.",
|
||||
"",
|
||||
f"Project root: {root}",
|
||||
f"Max age: {max_age_days} days",
|
||||
f"Expired upload tickets deleted: {result['expired_upload_tickets_deleted']}",
|
||||
f"Old ingest uploads deleted: {result['old_ingest_uploads_deleted']}",
|
||||
f"Old ingest jobs/logs deleted: {result['old_ingest_jobs_deleted']}",
|
||||
f"Old query jobs/logs deleted: {result['old_query_jobs_deleted']}",
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
|
||||
def _active_ingest_upload_ids(jobs_root: Path) -> set[str]:
|
||||
upload_ids: set[str] = set()
|
||||
for job in _iter_job_records(jobs_root):
|
||||
if job.get("state") in TERMINAL_JOB_STATES:
|
||||
continue
|
||||
upload_ids.update(str(upload_id) for upload_id in job.get("upload_ids", []))
|
||||
return upload_ids
|
||||
|
||||
|
||||
def _delete_expired_upload_tickets(ticket_root: Path) -> int:
|
||||
if not ticket_root.exists():
|
||||
return 0
|
||||
deleted = 0
|
||||
now = time.time()
|
||||
for ticket_path in ticket_root.glob("*.json"):
|
||||
meta = _read_json(ticket_path)
|
||||
if meta is None:
|
||||
continue
|
||||
try:
|
||||
expired = now > float(meta.get("expires_epoch", 0))
|
||||
except (TypeError, ValueError):
|
||||
expired = False
|
||||
if not expired:
|
||||
continue
|
||||
ticket_path.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
|
||||
def _delete_old_ingest_uploads(upload_root: Path, *, cutoff: float, active_upload_ids: set[str]) -> int:
|
||||
if not upload_root.exists():
|
||||
return 0
|
||||
deleted = 0
|
||||
for meta_path in upload_root.glob("*/*/upload.json"):
|
||||
upload_dir = meta_path.parent
|
||||
meta = _read_json(meta_path)
|
||||
if meta is None:
|
||||
continue
|
||||
if str(meta.get("upload_id", "")) in active_upload_ids:
|
||||
continue
|
||||
if _tree_mtime(upload_dir) >= cutoff:
|
||||
continue
|
||||
shutil.rmtree(upload_dir, ignore_errors=True)
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
|
||||
def _delete_old_terminal_jobs(jobs_root: Path, *, cutoff: float) -> int:
|
||||
if not jobs_root.exists():
|
||||
return 0
|
||||
deleted = 0
|
||||
for job in _iter_job_records(jobs_root):
|
||||
if job.get("state") not in TERMINAL_JOB_STATES:
|
||||
continue
|
||||
job_id = str(job.get("job_id", ""))
|
||||
job_dir = jobs_root / job_id
|
||||
if not job_dir.exists() or _tree_mtime(job_dir) >= cutoff:
|
||||
continue
|
||||
shutil.rmtree(job_dir, ignore_errors=True)
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
|
||||
def _iter_job_records(jobs_root: Path) -> list[dict[str, object]]:
|
||||
jobs: list[dict[str, object]] = []
|
||||
if not jobs_root.exists():
|
||||
return jobs
|
||||
for job_path in jobs_root.glob("*/job.json"):
|
||||
record = _read_json(job_path)
|
||||
if record is not None:
|
||||
jobs.append(record)
|
||||
return jobs
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _tree_mtime(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return time.time()
|
||||
latest = path.stat().st_mtime
|
||||
for child in path.rglob("*"):
|
||||
try:
|
||||
latest = max(latest, child.stat().st_mtime)
|
||||
except OSError:
|
||||
continue
|
||||
return latest
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Manage the readonly HTTP obsidian-wiki MCP user service.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
@@ -734,6 +867,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
subparsers.add_parser("status", help="Show systemd status for the managed service.")
|
||||
|
||||
cleanup_parser = subparsers.add_parser("cleanup", help="Prune expired tickets and old MCP runtime uploads/jobs/logs.")
|
||||
cleanup_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
cleanup_parser.add_argument("--max-age-days", default=DEFAULT_RUNTIME_CLEANUP_DAYS, type=int)
|
||||
|
||||
tools_parser = subparsers.add_parser("tools", help="List or update enabled MCP tool groups in the service config.")
|
||||
tools_subparsers = tools_parser.add_subparsers(dest="tools_command", required=True)
|
||||
|
||||
@@ -791,6 +928,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if args.command == "status":
|
||||
service_status()
|
||||
return 0
|
||||
if args.command == "cleanup":
|
||||
print(cleanup_runtime(args.project_root, max_age_days=args.max_age_days), end="")
|
||||
return 0
|
||||
if args.command == "tools":
|
||||
if args.tools_command == "list":
|
||||
print(list_tools(args.project_root), end="")
|
||||
|
||||
Reference in New Issue
Block a user