diff --git a/.gitignore b/.gitignore index e480ccd..0bcc771 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ srv/ # OS .DS_Store .claude/ +/mcp-cli +/mcp-master diff --git a/docs/edge-routing-design.md b/docs/edge-routing-design.md new file mode 100644 index 0000000..2865e01 --- /dev/null +++ b/docs/edge-routing-design.md @@ -0,0 +1,239 @@ +# Edge Routing Design + +**Status: SUPERSEDED by ARCHITECTURE_V2.md (2026-04-01).** + +This draft used agent-to-agent communication, a single shared cert with +both SANs, private key transmission over gRPC, and an `edge` field on +routes. All of these were replaced in the v2 architecture by master- +mediated coordination, two separate certs, edge-local cert provisioning, +and `public = true` on routes. Retained for historical context only. + +--- + +Draft design for publicly accessible services via edge nodes. + +--- + +## Problem + +Services run on internal nodes (rift, hyperborea) behind mc-proxy. To +make a service publicly accessible, an edge node (svc) needs an mc-proxy +route pointing back to the internal node. Today this is manual: +configure svc's mc-proxy, provision a cert, set up DNS. MCP should +automate everything except DNS. + +## Model + +A route in the service definition gains an `edge` field naming the +public-facing node: + +```toml +[[components.routes]] +port = 443 +mode = "l7" +hostname = "kls.metacircular.net" +edge = "svc" +``` + +When `edge` is set, MCP registers the route on **two** mc-proxy +instances: + +1. **Local** (rift mc-proxy): `kls.metacircular.net` → + `127.0.0.1:`. Internal access. +2. **Edge** (svc mc-proxy): `kls.metacircular.net` → + `:`. Public access. + +## Agent-to-Agent Coordination + +mc-proxy is a local concern — each node's MCP agent is the sole +authority for its mc-proxy. Cross-node route registration goes through +agent-to-agent gRPC, not direct mc-proxy access. + +### Deploy Flow + +``` +mcp deploy kls + │ + ▼ +rift agent (node where container runs) + ├── pull image, start container + ├── allocate host port + ├── provision TLS cert (both SANs, see below) + ├── register route on rift mc-proxy (local) + │ hostname: kls.metacircular.net + │ backend: 127.0.0.1: + │ cert: /srv/mc-proxy/certs/kls.pem (internal SAN) + │ + └── call svc agent: RegisterEdgeRoute + ├── hostname: kls.metacircular.net + ├── backend: : + ├── mode: l7 + └── cert: pushed with request (public SAN) + │ + ▼ + svc agent + ├── write cert to /srv/mc-proxy/certs/kls.pem + └── register route on svc mc-proxy (local) + hostname: kls.metacircular.net + backend: : +``` + +### Undeploy Flow + +Reverse of deploy. Rift agent calls svc agent to remove the edge route, +then tears down locally. + +## TLS Certificates + +There are two hostnames involved: + +| Hostname | Where used | Purpose | +|----------|-----------|---------| +| `kls.svc.mcp.metacircular.net` | rift mc-proxy | Internal platform access | +| `kls.metacircular.net` | svc mc-proxy | Public internet access | + +### Options + +**Option A: Two separate certs.** Rift's agent provisions one cert with +SAN `kls.svc.mcp.metacircular.net` for the internal route. For the edge +route, the rift agent (or svc agent) provisions a second cert with SAN +`kls.metacircular.net` and pushes it to svc. + +**Option B: Single cert with both SANs.** The agent requests one cert +from Metacrypt with both `kls.svc.mcp.metacircular.net` and +`kls.metacircular.net` as SANs. This cert is used on both rift and svc +mc-proxy instances. + +Option B is simpler (one cert to manage, one renewal) but means internal +certs carry public hostnames. Option A is cleaner separation but doubles +the cert management. + +### Recommendation + +Option B (single cert, both SANs) for simplicity. The Metacrypt CA is +internal anyway — both names are under our control. The cert issuance +request already accepts a list of `dns_names`, so this is a natural fit: + +```json +{ + "dns_names": [ + "kls.svc.mcp.metacircular.net", + "kls.metacircular.net" + ] +} +``` + +The agent pushes the same cert to both rift and svc mc-proxy cert dirs. + +### Trust Consideration + +The Metacrypt CA cert is not publicly trusted. External clients hitting +`kls.metacircular.net` will see an untrusted cert. This is acceptable +for now (platform services are operator-facing, `-k` or CA trust is +expected). Future option: Let's Encrypt / ACME on svc mc-proxy for +publicly trusted certs. + +## New RPC: RegisterEdgeRoute + +```protobuf +rpc RegisterEdgeRoute(RegisterEdgeRouteRequest) + returns (RegisterEdgeRouteResponse); + +message RegisterEdgeRouteRequest { + string hostname = 1; + string backend = 2; // e.g. "100.95.252.120:13881" + string mode = 3; // "l4" or "l7" + bytes cert_pem = 4; // TLS cert chain + bytes key_pem = 5; // TLS private key + string service = 6; // service name (for cert file naming) +} + +message RegisterEdgeRouteResponse { + bool success = 1; + string error = 2; +} +``` + +A corresponding `RemoveEdgeRoute` RPC for undeploy: + +```protobuf +rpc RemoveEdgeRoute(RemoveEdgeRouteRequest) + returns (RemoveEdgeRouteResponse); + +message RemoveEdgeRouteRequest { + string hostname = 1; + string service = 2; + int32 port = 3; // mc-proxy listener port +} + +message RemoveEdgeRouteResponse { + bool success = 1; + string error = 2; +} +``` + +## Agent Config Changes + +The agent needs to know how to reach other agents for edge route +registration. This could be: + +1. **Explicit config** — agent config lists peer agents: + +```toml +[[peers]] +name = "svc" +address = "100.x.x.x:9444" +``` + +2. **Discovered from node registry** — the agent already receives node + info during sync. If the CLI pushes node addresses to agents, the + agent can dial peers without extra config. + +Option 2 is preferable (less config duplication) but requires the agent +to have MCIAS credentials for authenticating to the peer agent. + +## Service Definition Changes + +Proto: add `edge` field to `RouteSpec`: + +```protobuf +message RouteSpec { + string name = 1; + int32 port = 2; + string mode = 3; + string hostname = 4; + string edge = 5; // edge node name (e.g. "svc") +} +``` + +Servicedef: add `Edge` field to `RouteDef`: + +```go +type RouteDef struct { + Name string `toml:"name,omitempty"` + Port int `toml:"port"` + Mode string `toml:"mode,omitempty"` + Hostname string `toml:"hostname,omitempty"` + Edge string `toml:"edge,omitempty"` +} +``` + +## Prerequisites + +1. MCP agent provisioned on svc. +2. mc-proxy running on svc with gRPC admin socket (currently config-only + on svc, per svc-deployment-plan.md). +3. Agent-to-agent authentication (MCIAS service credentials). + +## Open Questions + +- Should the internal route (`kls.svc.mcp.metacircular.net`) also be + registered on rift mc-proxy when an edge route exists? Or does the + edge route replace the internal one? Probably both — internal for + platform use, edge for public. +- Port for the public mc-proxy listener: should it always be 443 for + L7, or should the route's `port` field apply? For public services, + 443 is the only sensible choice. +- Should the edge route backend use TLS to the internal node + (`backend_tls = true`)? Yes — traffic crosses Tailscale but + defense-in-depth is good practice. diff --git a/script/deploy-agent b/script/deploy-agent new file mode 100755 index 0000000..fba8c11 --- /dev/null +++ b/script/deploy-agent @@ -0,0 +1,42 @@ +#!/bin/sh +# deploy-agent — build mcp-agent and deploy to a node via SSH. +# +# Usage: script/deploy-agent [arch] +# +# Builds the mcp-agent binary for the target architecture, copies it +# to /srv/mcp/mcp-agent on the node, and restarts the systemd service. +# +# Arguments: +# node SSH hostname (e.g., rift, hyperborea) +# arch GOARCH value (default: amd64) +# +# Examples: +# script/deploy-agent rift +# script/deploy-agent hyperborea arm64 + +set -eu + +NODE="${1:?usage: deploy-agent [arch]}" +ARCH="${2:-amd64}" +REMOTE_PATH="/srv/mcp/mcp-agent" +SERVICE="mcp-agent" + +VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo unknown)" +BINARY="mcp-agent.${NODE}" + +printf "building mcp-agent %s for %s (%s)\n" "$VERSION" "$NODE" "$ARCH" +CGO_ENABLED=0 GOARCH="$ARCH" go build \ + -trimpath \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o "$BINARY" \ + ./cmd/mcp-agent + +printf "deploying to %s:%s\n" "$NODE" "$REMOTE_PATH" +scp "$BINARY" "${NODE}:/tmp/mcp-agent.new" +ssh "$NODE" "doas sh -c 'systemctl stop ${SERVICE} && cp /tmp/mcp-agent.new ${REMOTE_PATH} && systemctl start ${SERVICE}' && rm /tmp/mcp-agent.new" + +printf "verifying %s on %s\n" "$SERVICE" "$NODE" +ssh "$NODE" "systemctl is-active ${SERVICE}" + +rm -f "$BINARY" +printf "done: %s deployed to %s\n" "$VERSION" "$NODE"