From e2dd097a37e8dac60c895fd2d05930e3711f6207 Mon Sep 17 00:00:00 2001 From: commdevin Date: Fri, 11 Sep 2026 22:49:43 +0000 Subject: [PATCH 1/4] feat: PIP_MCP_PORT setting, MCP /healthz exempt from bearer, sqlite busy timeout, env example Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 2 + README.md | 8 +++ config/.env.example | 5 +- docs/PROTOCOL.md | 1 + src/pip_protocol/cli.py | 2 +- src/pip_protocol/config.py | 1 + src/pip_protocol/mcp_server.py | 49 +++++++++++++------ src/pip_protocol/store.py | 2 +- tests/test_deploy.py | 89 ++++++++++++++++++++++++++++++++++ 9 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 tests/test_deploy.py diff --git a/.gitignore b/.gitignore index 96e1dd0..2c8ae71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.env +config/policy.yaml .venv/ venv/ data/ diff --git a/README.md b/README.md index a340be4..20debc1 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,20 @@ Prompts: `pip_compose_message(to, subject, intent)`, | `PIP_POLICY_FILE` | `./config/policy.yaml` | YAML or JSON | | `PIP_STORE_URL` | `memory://` | or `sqlite:///data/pip.db` | | `PIP_HTTP_HOST` / `PIP_HTTP_PORT` | `127.0.0.1` / `8642` | loopback by default | +| `PIP_MCP_PORT` | `8643` | streamable-http MCP port | | `PIP_PUBLIC_HTTP_URL` / `PIP_PUBLIC_MCP_URL` | unset | advertised endpoints | | `PIP_HTTP_BEARER_TOKEN` | unset | optional transport gate | | `PIP_MAX_CLOCK_SKEW_SECONDS` | `300` | | | `PIP_IDEMPOTENCY_TTL_SECONDS` | `86400` | | | `PIP_LOG_LEVEL` | `INFO` | JSON structured logs | +## Deployment + +Container image, compose stack, TLS proxy examples, and a systemd unit live in +`Dockerfile`, `docker-compose.yml`, `docker/`, and `deploy/`. See +[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md); quick path: +`./deploy/deploy.sh up`. + ## Security model (summary) Ed25519 signatures over canonical JSON (`"PIPv1\n" + canonical_json(env-sig)`); diff --git a/config/.env.example b/config/.env.example index d2afc40..7192d27 100644 --- a/config/.env.example +++ b/config/.env.example @@ -1,4 +1,5 @@ # Example PIP environment configuration (spec §12). No real secrets here. +# Copy to .env (for docker compose) or export individually. PIP_PRIVATE_KEY_FILE=./data/instance.key PIP_DISPLAY_NAME=poke PIP_POLICY_FILE=./config/policy.yaml @@ -6,9 +7,11 @@ PIP_STORE_URL=memory:// # PIP_STORE_URL=sqlite:///data/pip.db PIP_HTTP_HOST=127.0.0.1 PIP_HTTP_PORT=8642 +PIP_MCP_PORT=8643 # PIP_PUBLIC_HTTP_URL=https://poke.example.com # PIP_PUBLIC_MCP_URL= -# PIP_HTTP_BEARER_TOKEN= +# optional defense-in-depth transport gate; generate with: openssl rand -hex 32 +PIP_HTTP_BEARER_TOKEN= PIP_MAX_CLOCK_SKEW_SECONDS=300 PIP_IDEMPOTENCY_TTL_SECONDS=86400 PIP_LOG_LEVEL=INFO diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index cee0821..2e66695 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -222,6 +222,7 @@ Settings via environment (`pydantic-settings`, prefix `PIP_`): | `PIP_POLICY_FILE` | `./config/policy.yaml` | | | `PIP_STORE_URL` | `memory://` | or `sqlite:///data/pip.db` | | `PIP_HTTP_HOST` / `PIP_HTTP_PORT` | `127.0.0.1` / `8642` | | +| `PIP_MCP_PORT` | `8643` | streamable-http MCP port | | `PIP_PUBLIC_HTTP_URL` / `PIP_PUBLIC_MCP_URL` | unset | advertised endpoints | | `PIP_HTTP_BEARER_TOKEN` | unset | optional transport gate | | `PIP_MAX_CLOCK_SKEW_SECONDS` | `300` | | diff --git a/src/pip_protocol/cli.py b/src/pip_protocol/cli.py index 4c723bc..5fa4aef 100644 --- a/src/pip_protocol/cli.py +++ b/src/pip_protocol/cli.py @@ -57,7 +57,7 @@ def _cmd_serve_mcp(args: argparse.Namespace) -> int: server, args.transport, host=settings.http_host, - port=settings.http_port, + port=settings.mcp_port, bearer_token=settings.http_bearer_token, ) return 0 diff --git a/src/pip_protocol/config.py b/src/pip_protocol/config.py index b57e38b..3a615b3 100644 --- a/src/pip_protocol/config.py +++ b/src/pip_protocol/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): store_url: str = "memory://" http_host: str = "127.0.0.1" http_port: int = 8642 + mcp_port: int = 8643 public_http_url: str | None = None public_mcp_url: str | None = None http_bearer_token: str | None = None diff --git a/src/pip_protocol/mcp_server.py b/src/pip_protocol/mcp_server.py index 78a03f7..2f4f3b0 100644 --- a/src/pip_protocol/mcp_server.py +++ b/src/pip_protocol/mcp_server.py @@ -4,6 +4,7 @@ from typing import Any, Literal from mcp.server.fastmcp import FastMCP +from starlette.applications import Starlette from .config import Settings from .errors import ErrorCode, PipError @@ -150,6 +151,37 @@ def pip_request_data(dataset: str, purpose: str) -> str: return server +def build_mcp_http_app(server: FastMCP, bearer_token: str | None = None) -> Starlette: + """Wrap the streamable-http app with /healthz and an optional bearer gate. + + /healthz is exempt from the bearer check (health probes carry no tokens). + """ + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.requests import Request + from starlette.responses import JSONResponse + from starlette.routing import Mount, Route + + from .transport.http import bearer_ok + + inner = server.streamable_http_app() + + async def healthz(request: Request) -> JSONResponse: + return JSONResponse({"status": "ok", "version": "pip/1.0", "transport": "mcp"}) + + class _Bearer(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] + if request.url.path != "/healthz" and not bearer_ok( + request.headers.get("authorization"), bearer_token + ): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return await call_next(request) + + app = Starlette(routes=[Route("/healthz", healthz), Mount("/", app=inner)]) + if bearer_token is not None: + app.add_middleware(_Bearer) + return app + + def run_mcp( server: FastMCP, transport: Literal["stdio", "streamable-http"] = "stdio", @@ -161,21 +193,6 @@ def run_mcp( if transport == "stdio": server.run("stdio") return - app = server.streamable_http_app() - if bearer_token is not None: - from starlette.middleware.base import BaseHTTPMiddleware - from starlette.requests import Request - from starlette.responses import JSONResponse - - from .transport.http import bearer_ok - - class _Bearer(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] - if not bearer_ok(request.headers.get("authorization"), bearer_token): - return JSONResponse({"error": "unauthorized"}, status_code=401) - return await call_next(request) - - app.add_middleware(_Bearer) import uvicorn - uvicorn.run(app, host=host, port=port) + uvicorn.run(build_mcp_http_app(server, bearer_token), host=host, port=port) diff --git a/src/pip_protocol/store.py b/src/pip_protocol/store.py index f210726..10c5b5b 100644 --- a/src/pip_protocol/store.py +++ b/src/pip_protocol/store.py @@ -150,7 +150,7 @@ def outbox_delete(self, item_id: str) -> None: class SqliteStore: def __init__(self, path: str) -> None: self._lock = threading.Lock() - self._conn = sqlite3.connect(path, check_same_thread=False) + self._conn = sqlite3.connect(path, check_same_thread=False, timeout=5) self._conn.execute("PRAGMA journal_mode=WAL") with self._conn: self._conn.executescript(_SCHEMA) diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..484ebd7 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,89 @@ +import os +import subprocess +from pathlib import Path + +import yaml + +from pip_protocol.config import Settings + +ROOT = Path(__file__).resolve().parent.parent + + +def test_env_example_covers_settings_and_has_no_secrets(): + text = (ROOT / "config" / ".env.example").read_text() + assert "PIP_HTTP_BEARER_TOKEN=\n" in text or text.endswith("PIP_HTTP_BEARER_TOKEN=") + for name in Settings.model_fields: + assert f"PIP_{name.upper()}" in text, name + # no real values: every uncommented assignment is empty or a safe default + for line in text.splitlines(): + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + assert not any(s in value.lower() for s in ("secret", "token=", "hunter")), line + + +def test_compose_ports_loopback_only(): + compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text()) + for svc in compose["services"].values(): + for port in svc["ports"]: + assert str(port).split(":")[0] == "127.0.0.1", port + assert "pip-data" in compose["volumes"] + for svc in compose["services"].values(): + assert svc["read_only"] is True + assert svc["cap_drop"] == ["ALL"] + + +def test_dockerfile_hardening(): + text = (ROOT / "Dockerfile").read_text() + assert "USER pip" in text + assert "HEALTHCHECK" in text + assert "EXPOSE 8642 8643" in text + + +def test_scripts_executable(): + out = subprocess.run( + ["/usr/bin/git", "ls-files", "-s", "docker/entrypoint.sh", "deploy/deploy.sh"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + for path in ("docker/entrypoint.sh", "deploy/deploy.sh"): + assert os.access(ROOT / path, os.X_OK), path + tracked = [ln for ln in out.splitlines() if ln.endswith(path)] + if tracked: # once committed, git must record the exec bit + assert tracked[0].split()[0] == "100755", tracked[0] + + +def test_mcp_healthz_no_token(): + from starlette.testclient import TestClient + + from pip_protocol.identity import KeyPair + from pip_protocol.mcp_server import build_mcp_http_app, create_mcp_server + from pip_protocol.node import Node + + node = Node(KeyPair.generate()) + app = build_mcp_http_app(create_mcp_server(node)) + client = TestClient(app) + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok", "version": "pip/1.0", "transport": "mcp"} + + +def test_mcp_healthz_exempt_and_mcp_gated(): + from starlette.testclient import TestClient + + from pip_protocol.identity import KeyPair + from pip_protocol.mcp_server import build_mcp_http_app, create_mcp_server + from pip_protocol.node import Node + + node = Node(KeyPair.generate()) + app = build_mcp_http_app( + create_mcp_server(node), + bearer_token="tok123", # noqa: S106 + ) + client = TestClient(app, raise_server_exceptions=False) + assert client.get("/healthz").status_code == 200 # probes carry no token + assert client.post("/mcp", json={}).status_code == 401 + # authorized requests pass the gate (session manager needs a real lifespan) + resp = client.post("/mcp", json={}, headers={"Authorization": "Bearer tok123"}) # noqa: S106 + assert resp.status_code != 401 From da227e6ad7de9c60a7658dc716c89af4cbd482f8 Mon Sep 17 00:00:00 2001 From: commdevin Date: Fri, 11 Sep 2026 22:49:43 +0000 Subject: [PATCH 2/4] feat: container image and compose stack (non-root, loopback ports, healthchecks) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .dockerignore | 15 ++++++++++++ Dockerfile | 31 +++++++++++++++++++++++++ docker-compose.yml | 54 ++++++++++++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 23 +++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..70d0532 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.venv +venv +data +tests +*.key +.env +__pycache__ +.pytest_cache +.mypy_cache +.ruff_cache +docs +examples +deploy +docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..736c655 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.12-slim AS builder +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv +WORKDIR /app +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN uv venv /opt/venv && /opt/venv/bin/python -V \ + && uv pip install --python /opt/venv/bin/python . + +FROM python:3.12-slim AS runtime +RUN groupadd -g 10001 pip && useradd -u 10001 -g pip -m pip +COPY --from=builder /opt/venv /opt/venv +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh \ + && mkdir -p /data /config && chown pip:pip /data +ENV PATH=/opt/venv/bin:$PATH \ + PYTHONUNBUFFERED=1 \ + PIP_PRIVATE_KEY_FILE=/data/instance.key \ + PIP_POLICY_FILE=/config/policy.yaml \ + PIP_STORE_URL=sqlite:////data/pip.db \ + PIP_HTTP_HOST=0.0.0.0 \ + PIP_HTTP_PORT=8642 \ + PIP_MCP_PORT=8643 +VOLUME ["/data"] +USER pip +EXPOSE 8642 8643 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["python", "-c", "import urllib.request,os,sys;sys.exit(0 if urllib.request.urlopen(f\"http://127.0.0.1:{os.environ.get('PIP_HEALTH_PORT','8642')}/healthz\",timeout=2).status==200 else 1)"] +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["serve-http"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1a6e9bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + pip-http: + build: . + command: ["serve-http"] + ports: + - "127.0.0.1:8642:8642" + env_file: + - path: .env + required: false + environment: + # these must beat env_file defaults inside a container + PIP_HEALTH_PORT: "8642" + PIP_HTTP_HOST: "0.0.0.0" + PIP_STORE_URL: "sqlite:////data/pip.db" + volumes: + - pip-data:/data + - ./config/policy.yaml:/config/policy.yaml:ro + restart: unless-stopped + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + + pip-mcp: + build: . + command: ["serve-mcp"] + ports: + - "127.0.0.1:8643:8643" + env_file: + - path: .env + required: false + environment: + PIP_HEALTH_PORT: "8643" + PIP_HTTP_HOST: "0.0.0.0" + PIP_STORE_URL: "sqlite:////data/pip.db" + volumes: + # same volume → same identity key; sqlite WAL + busy timeout handle + # concurrent access from both processes + - pip-data:/data + - ./config/policy.yaml:/config/policy.yaml:ro + restart: unless-stopped + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + pip-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..da04ab2 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# PIP container entrypoint. First arg selects the subcommand. +# Never echo env values — they may carry the bearer token. +set -euo pipefail + +if [ ! -f "${PIP_POLICY_FILE:-/config/policy.yaml}" ]; then + echo "warning: ${PIP_POLICY_FILE:-/config/policy.yaml} not found; starting with empty policy" >&2 +fi + +cmd="${1:-serve-http}" +case "$cmd" in + serve-http) + exec pip-node serve-http + ;; + serve-mcp) + # HEALTHCHECK reads PIP_HEALTH_PORT from the container env — set it + # per-service in compose (or -e PIP_HEALTH_PORT=8643 for docker run). + exec pip-node serve-mcp --transport streamable-http + ;; + *) + exec pip-node "$@" + ;; +esac From 7e9d0b3178ab90be790f8dc3987ae0b48e3a477a Mon Sep 17 00:00:00 2001 From: commdevin Date: Fri, 11 Sep 2026 22:49:43 +0000 Subject: [PATCH 3/4] feat: deploy script, TLS proxy example, systemd units, deployment docs, docker CI job Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 32 +++++++ CHANGELOG.md | 10 +++ deploy/Caddyfile.example | 11 +++ deploy/deploy.sh | 54 +++++++++++ deploy/systemd/pip-mcp.service.example | 18 ++++ deploy/systemd/pip-node.service.example | 18 ++++ docs/DEPLOYMENT.md | 114 ++++++++++++++++++++++++ 7 files changed, 257 insertions(+) create mode 100644 deploy/Caddyfile.example create mode 100755 deploy/deploy.sh create mode 100644 deploy/systemd/pip-mcp.service.example create mode 100644 deploy/systemd/pip-node.service.example create mode 100644 docs/DEPLOYMENT.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35e0248..7f8c252 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,35 @@ jobs: run: .venv/bin/mypy src - name: Pytest run: .venv/bin/pytest -q + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build -t poke-interconnect:ci . + - name: Validate compose + run: docker compose config -q + - name: Run container + run: >- + docker run -d --name pip-ci + -e PIP_ALLOW_INSECURE_KEY_PERMS=1 + -p 127.0.0.1:8642:8642 + poke-interconnect:ci + - name: Wait for health + run: | + for i in $(seq 1 30); do + curl -sf http://127.0.0.1:8642/healthz && break || sleep 1 + done + for i in $(seq 1 30); do + s=$(docker inspect --format '{{.State.Health.Status}}' pip-ci) + echo "health: $s"; [ "$s" = healthy ] && exit 0; sleep 1 + done + docker logs pip-ci; exit 1 + - name: Check handshake + run: >- + curl -sf http://127.0.0.1:8642/.well-known/pip | + python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["type"]=="handshake"' + - name: Cleanup + if: always() + run: docker logs pip-ci; docker rm -f pip-ci diff --git a/CHANGELOG.md b/CHANGELOG.md index 531a706..61b63e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.1.0 + +- Added `PIP_MCP_PORT` (default 8643) — MCP streamable-http now binds its own port. +- MCP streamable-http gains a `/healthz` endpoint exempt from the bearer gate. +- Production deployment: multi-stage `Dockerfile` (non-root, healthcheck), + `docker-compose.yml` (loopback-only ports, read-only fs, cap_drop), + `docker/entrypoint.sh`, `deploy/` (deploy.sh, Caddyfile, systemd units), + `docs/DEPLOYMENT.md`. +- `SqliteStore` sets a 5s busy timeout for multi-process WAL access. + ## 1.0.0 Initial release of the Poke Interconnect Protocol (PIP) v1. diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 0000000..df910e1 --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,11 @@ +# Example Caddyfile: TLS reverse proxy in front of PIP. +# PIP binds its ports to loopback only; terminate TLS here. +# Set PIP_PUBLIC_HTTP_URL / PIP_PUBLIC_MCP_URL to the public https URLs below. + +poke.example.com { + reverse_proxy 127.0.0.1:8642 +} + +poke-mcp.example.com { + reverse_proxy 127.0.0.1:8643 +} diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..466670f --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Deploy helper for the PIP docker compose stack. +# Usage: deploy.sh [up|down|status|logs|keygen|identity] +set -euo pipefail + +cd "$(dirname "$0")/.." +COMPOSE="docker compose" + +wait_health() { + local port="$1" name="$2" i + for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then + echo "${name}: healthy on :${port}" + return 0 + fi + sleep 1 + done + echo "${name}: NOT healthy on :${port} after 30s" >&2 + return 1 +} + +case "${1:-up}" in + up) + if [ ! -f .env ]; then + cp config/.env.example .env + echo "warning: created .env from config/.env.example — EDIT IT before exposing publicly" >&2 + fi + if [ ! -f config/policy.yaml ]; then + cp config/policy.example.yaml config/policy.yaml + echo "warning: created config/policy.yaml from the example — peers are placeholders" >&2 + fi + $COMPOSE build + $COMPOSE up -d + wait_health 8642 pip-http + wait_health 8643 pip-mcp + ;; + down) + $COMPOSE down + ;; + status) + $COMPOSE ps + ;; + logs) + shift || true + $COMPOSE logs -f "$@" + ;; + keygen|identity) + $COMPOSE run --rm pip-http "$1" + ;; + *) + echo "usage: $0 [up|down|status|logs|keygen|identity]" >&2 + exit 2 + ;; +esac diff --git a/deploy/systemd/pip-mcp.service.example b/deploy/systemd/pip-mcp.service.example new file mode 100644 index 0000000..e213742 --- /dev/null +++ b/deploy/systemd/pip-mcp.service.example @@ -0,0 +1,18 @@ +# /etc/systemd/system/pip-mcp.service — PIP MCP transport (streamable-http) +[Unit] +Description=PIP node (MCP streamable-http transport) +After=network.target + +[Service] +User=pip +Group=pip +EnvironmentFile=/etc/pip-node/env +ExecStart=/opt/pip-node/.venv/bin/pip-node serve-mcp --transport streamable-http +Restart=on-failure +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/var/lib/pip-node +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/pip-node.service.example b/deploy/systemd/pip-node.service.example new file mode 100644 index 0000000..f64b4d3 --- /dev/null +++ b/deploy/systemd/pip-node.service.example @@ -0,0 +1,18 @@ +# /etc/systemd/system/pip-node.service — PIP HTTP transport +[Unit] +Description=PIP node (HTTP transport) +After=network.target + +[Service] +User=pip +Group=pip +EnvironmentFile=/etc/pip-node/env +ExecStart=/opt/pip-node/.venv/bin/pip-node serve-http +Restart=on-failure +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/var/lib/pip-node +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..53407d9 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,114 @@ +# Deploying a PIP node + +## Prerequisites + +- Docker + docker compose v2, **or** Python ≥3.10 + uv for a bare-metal run. +- A policy file listing your peers (`config/policy.yaml`, see + `config/policy.example.yaml`). + +## Local run (no containers) + +```bash +uv venv .venv && uv pip install -e . +pip-node keygen --out ./data/instance.key +cp config/policy.example.yaml config/policy.yaml # edit peers +./.venv/bin/pip-node serve-http # :8642 +./.venv/bin/pip-node serve-mcp --transport streamable-http # :8643 +``` + +## Container run + +```bash +docker build -t poke-interconnect . + +# one-off helpers (key ends up in the named volume) +docker run --rm -v pip-data:/data poke-interconnect keygen +docker run --rm -v pip-data:/data poke-interconnect identity + +# HTTP transport +docker run -d --name pip-http \ + -v pip-data:/data -v $PWD/config/policy.yaml:/config/policy.yaml:ro \ + -p 127.0.0.1:8642:8642 -e PIP_HEALTH_PORT=8642 \ + poke-interconnect serve-http + +# MCP streamable-http transport (same volume → same identity) +docker run -d --name pip-mcp \ + -v pip-data:/data -v $PWD/config/policy.yaml:/config/policy.yaml:ro \ + -p 127.0.0.1:8643:8643 -e PIP_HEALTH_PORT=8643 \ + poke-interconnect serve-mcp +``` + +The image runs as non-root `pip` (uid 10001), generates the instance key into +`/data` on first start if absent, and health-checks +`$PIP_HEALTH_PORT/healthz` (8642 for `serve-http`, 8643 for `serve-mcp`). + +## Compose (recommended) + +```bash +./deploy/deploy.sh up # builds, starts both services, waits for health +./deploy/deploy.sh status|logs|down +./deploy/deploy.sh identity # print the node identity document +``` + +Both services share the `pip-data` volume (same key + sqlite store; WAL mode +plus a 5s busy timeout make the concurrent access safe). Ports bind to +`127.0.0.1` only — never publish 8642/8643 on a public interface. + +## TLS + +Terminate TLS at a reverse proxy; PIP v1 has no in-transport encryption. +See `deploy/Caddyfile.example`. Equivalent nginx: `proxy_pass +http://127.0.0.1:8642;`. Set `PIP_PUBLIC_HTTP_URL`/`PIP_PUBLIC_MCP_URL` to the +public https URLs so your identity document advertises them. + +## Key handling + +- The private key lives only at `/data/instance.key` (0600, uid 10001). Back up + the `pip-data` volume; losing it means a new identity. +- Rotation: publish `previous_keys` in the identity document (see + `docs/PROTOCOL.md` §2/§4). + +## Policy management + +Edit `config/policy.yaml` (mounted read-only into the container), then +`docker compose restart`. Peers, scopes, consents, rate limits, redaction. + +## Bearer token (optional) + +`PIP_HTTP_BEARER_TOKEN=` gates `/pip/v1/inbox`, `/metrics`, and the MCP +streamable-http endpoint. Generate with `openssl rand -hex 32`. `/healthz` is +always unauthenticated. + +## Health & metrics + +- `GET /healthz` on both transports → `{"status":"ok",...}`. +- `GET /metrics` on the HTTP transport → Prometheus text (bearer-gated if + configured). Scrape via the proxy or from the host. + +## Logs + +Structured JSON to stdout — `deploy/deploy.sh logs` or `docker compose logs -f`. +Never contain payload bodies, keys, or tokens. + +## Upgrades + +```bash +git pull && ./deploy/deploy.sh up # rebuilds image, restarts services +``` + +Data (key, sqlite store) survives in the `pip-data` volume. + +## systemd alternative (no containers) + +`deploy/systemd/pip-node.service.example` and `pip-mcp.service.example`: +install under a `pip` user, `EnvironmentFile=/etc/pip-node/env`, +`ReadWritePaths=/var/lib/pip-node`. + +## Checklist + +- [ ] `config/policy.yaml` reviewed (real peers, scopes, consents) +- [ ] `.env` reviewed; bearer token set if needed +- [ ] ports reachable only via the TLS proxy +- [ ] key backed up; volume snapshot scheduled +- [ ] `curl https:///.well-known/pip` returns a signed handshake +- [ ] metrics scraping + log shipping configured From 1b90bb94269a3caa9d513ae4a8337d4342292186 Mon Sep 17 00:00:00 2001 From: commdevin Date: Fri, 11 Sep 2026 22:52:30 +0000 Subject: [PATCH 4/4] fix: propagate MCP lifespan through the health/bearer wrapper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/pip_protocol/config.py | 7 +++++ src/pip_protocol/mcp_server.py | 5 +++- tests/test_deploy.py | 51 ++++++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/pip_protocol/config.py b/src/pip_protocol/config.py index 3a615b3..5e9cab2 100644 --- a/src/pip_protocol/config.py +++ b/src/pip_protocol/config.py @@ -1,5 +1,6 @@ """Runtime settings via environment, prefix PIP_ (spec §12).""" +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -20,3 +21,9 @@ class Settings(BaseSettings): idempotency_ttl_seconds: int = 86400 log_level: str = "INFO" allow_insecure_key_perms: bool = False + + @field_validator("public_http_url", "public_mcp_url", "http_bearer_token", mode="before") + @classmethod + def _empty_is_none(cls, v): # type: ignore[no-untyped-def] + # an empty value in .env files must not enable features + return None if v == "" else v diff --git a/src/pip_protocol/mcp_server.py b/src/pip_protocol/mcp_server.py index 2f4f3b0..a69c197 100644 --- a/src/pip_protocol/mcp_server.py +++ b/src/pip_protocol/mcp_server.py @@ -176,7 +176,10 @@ async def dispatch(self, request: Request, call_next): # type: ignore[no-untype return JSONResponse({"error": "unauthorized"}, status_code=401) return await call_next(request) - app = Starlette(routes=[Route("/healthz", healthz), Mount("/", app=inner)]) + app = Starlette( + routes=[Route("/healthz", healthz), Mount("/", app=inner)], + lifespan=inner.router.lifespan_context, + ) if bearer_token is not None: app.add_middleware(_Bearer) return app diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 484ebd7..14bd4dd 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -69,7 +69,32 @@ def test_mcp_healthz_no_token(): assert resp.json() == {"status": "ok", "version": "pip/1.0", "transport": "mcp"} -def test_mcp_healthz_exempt_and_mcp_gated(): +_MCP_INIT = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}, + }, +} +_MCP_HEADERS = {"Accept": "application/json, text/event-stream"} + + +def _mcp_body(resp): + import json + + if resp.headers["content-type"].startswith("text/event-stream"): + for line in resp.text.splitlines(): + if line.startswith("data:"): + return json.loads(line[len("data:") :]) + raise AssertionError("no data line in SSE response") + return resp.json() + + +def test_mcp_initialize_end_to_end(): + """Real MCP initialize POST exercises the propagated lifespan.""" from starlette.testclient import TestClient from pip_protocol.identity import KeyPair @@ -77,13 +102,17 @@ def test_mcp_healthz_exempt_and_mcp_gated(): from pip_protocol.node import Node node = Node(KeyPair.generate()) - app = build_mcp_http_app( - create_mcp_server(node), - bearer_token="tok123", # noqa: S106 - ) - client = TestClient(app, raise_server_exceptions=False) - assert client.get("/healthz").status_code == 200 # probes carry no token - assert client.post("/mcp", json={}).status_code == 401 - # authorized requests pass the gate (session manager needs a real lifespan) - resp = client.post("/mcp", json={}, headers={"Authorization": "Bearer tok123"}) # noqa: S106 - assert resp.status_code != 401 + app = build_mcp_http_app(create_mcp_server(node), bearer_token="tok123") # noqa: S106 + # loopback Host passes MCP's DNS-rebinding guard; context runs lifespan + # Host must match MCP allowed_hosts pattern "127.0.0.1:*" (port required) + with TestClient(app, base_url="http://127.0.0.1:8643") as client: + assert client.get("/healthz").status_code == 200 + assert client.post("/mcp", json=_MCP_INIT, headers=_MCP_HEADERS).status_code == 401 + resp = client.post( + "/mcp", + json=_MCP_INIT, + headers={**_MCP_HEADERS, "Authorization": "Bearer tok123"}, # noqa: S106 + ) + assert resp.status_code == 200, resp.text + body = _mcp_body(resp) + assert body["result"]["serverInfo"]["name"] == "pip-v1"