Compare commits

..
7 Commits
Author SHA1 Message Date
kyleandClaude Opus 4.6 95bec6a095 Sync docs/metacircular.md versions and add undeploy capability
Update version references to match current git tags: MCIAS v1.9.0,
Metacrypt v1.3.1, MCP v0.7.6. Add Phase D (DNS registration) to MCP
status, update RPC/CLI counts, and document undeploy as a first-class
capability. Also sync STATUS.md and packaging-and-deployment.md with
the same version updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:06:24 -07:00
kyleandClaude Opus 4.6 faf58ceb72 Update packaging docs: fix stale versions, add missing CLI commands
Sync the deployed services version table with current git tags (MCIAS
v1.9.0, Metacrypt v1.3.1, MCR v1.2.1, MCNS v1.1.1, MCP v0.7.6) and
add mcp logs and mcp edit to the command reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:06:13 -07:00
kyleandClaude Opus 4.6 bce32654e1 Sync platform docs: Phase D complete, Phase E planned, version updates
- PLATFORM_EVOLUTION: Mark Phase D (DNS) complete, add Phase E
  (multi-node agent management) planning with items #10-12
- PLATFORM_EVOLUTION: Fix stale mcdsl reference (v1.2.0 adds → added,
  consuming services now on v1.4.0)
- STATUS: Update all service versions to current, note Phase A-D
  completion and Phase E planning
- docs/packaging-and-deployment: Add agent management section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:05:37 -07:00
kyleandClaude Opus 4.6 0123e6e29a Update metacircular.md with system account auth model
Document the three-tier identity model across MCIAS, MCR, Metacrypt,
MCNS, and MCP. Update version numbers for MCR (v1.2.1), MCNS (v1.1.1),
MCP (v0.7.2). Clarify that admin is reserved for MCIAS administration,
not routine deploy operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:11:21 -07:00
kyleandClaude Opus 4.6 86bbfa640f Add CLI security standard: never echo passwords
New standard requiring mcdsl/terminal.ReadPassword for interactive
password prompts. Codifies the fix applied to MCP's login command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:11:53 -07:00
kyleandClaude Opus 4.6 cadbb3f234 Sync packaging docs with MCP implementation reality
Fix service definition examples to require explicit image fields,
remove phantom version top-level field, document container listen
address requirement (0.0.0.0:$PORT not localhost), add undeploy
command, and update deployed service versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:55:23 -07:00
kyleandClaude Opus 4.6 a777c3ff8b Mark Phase C complete in PLATFORM_EVOLUTION.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:59:35 -07:00
19 changed files with 95 additions and 4750 deletions
-11
View File
@@ -11,15 +11,4 @@
/mcns
/mcp
/mcdeploy
/mcdoc
# Local service configs and tool settings; these have held credentials.
/mcat.toml
/mcq.toml
/mcr.toml
/metacrypt.toml
/.claude/
# V1/V2 source checkouts, archived 2026-09-20. Each is its own git repo.
/archive/
+1 -1
View File
@@ -5,7 +5,7 @@
"command": "/home/kyle/.local/bin/gitea-mcp",
"env": {
"GITEA_HOST": "https://git.wntrmute.dev",
"GITEA_ACCESS_TOKEN": "${GITEA_ACCESS_TOKEN}"
"GITEA_ACCESS_TOKEN": "873c9bf3ef872e11ca8811621dba0d7e3762cad9"
}
}
}
+1 -11
View File
@@ -2,11 +2,6 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **2026-09-20 — V1/V2 are archived.** The platform was parked and is being
> redesigned as V3 (see `docs/v3/`). The source repos described below now live
> under `archive/v2/` (each still its own git repo, gitignored here). Paths in
> this document that name a component directory are relative to `archive/v2/`.
## Overview
Metacircular is a multi-service personal infrastructure platform. This root repository is a workspace container — each subdirectory is a separate Git repo (gitignored here). The authoritative platform-wide standards live in `engineering-standards.md`.
@@ -22,16 +17,12 @@ Metacircular is a multi-service personal infrastructure platform. This root repo
| `mcat/` | MCIAS login policy tester — lightweight web app to test and audit login policies | Go |
| `mcdsl/` | Standard library — shared packages for auth, db, config, HTTP/gRPC servers, CSRF, snapshots | Go |
| `mcdoc/` | Documentation server — renders markdown from Gitea, serves public docs via mc-proxy | Go |
| `mcp/` | Control plane — service deployment, container lifecycle, multi-node fleet management (CLI/agent, master in development) | Go |
| `mcp/` | Control plane — operator-driven deployment, service registry, container lifecycle (master/agent) | Go |
| `mcns/` | Networking service — custom Go DNS server, authoritative for internal zones | Go |
| `ca/` | PKI infrastructure and secrets for dev/test (not source code, gitignored) | — |
| `docs/` | Platform-wide documentation (architecture overview, deployment guide) | Markdown |
| `log/` | Operational work logs and session notes | Markdown |
Each subproject has its own `CLAUDE.md`, `ARCHITECTURE.md`, `Makefile`, and `go.mod`. When working in a subproject, read its own CLAUDE.md first.
Some subprojects live at sibling paths rather than as subdirectories. For example, `mcq` (document review queue) lives at `../mcq/`. This repository contains only core infrastructure services.
## Service Dependencies
MCIAS is the root dependency — every other service authenticates through it. No service maintains its own user database. The dependency graph:
@@ -59,7 +50,6 @@ make proto # regenerate gRPC code from .proto files
make proto-lint # buf lint + buf breaking
make devserver # build and run locally against srv/ config
make docker # build container image
make push # push container image to MCR
make clean # remove binaries
```
+5 -8
View File
@@ -5,7 +5,7 @@ from its current manually-wired state to fully declarative deployment.
It is a living design document — not a spec, not a commitment, but a
record of where we are, where we want to be, and what's between.
Last updated: 2026-04-02 (Phases AD complete, Phase E in progress)
Last updated: 2026-03-28 (Phases A + B + C + D complete)
---
@@ -324,11 +324,9 @@ mcproxyctl, TOML editing, cert generation, or DNS zone editing.
### Immediate Next Steps
1. **Phase E: Multi-node agent management** — see below. See
`docs/phase-e-plan.md` for the detailed Phase E design and
`docs/architecture-v2.md` for the v2 master/agent architecture.
2. **mcdoc** — deployed (v0.1.0 on rift). Serves rendered markdown
from Gitea via mc-proxy.
1. **Phase E: Multi-node agent management** — see below.
2. **mcdoc implementation** — fully designed, no platform evolution
dependency. Deployable now with the new route system.
#### 10. Agent Binary Location Convention
@@ -382,9 +380,8 @@ Different for NixOS vs Debian.
| Node | OS | Arch | Status |
|------|----|------|--------|
| rift | NixOS | amd64 | Operational, single MCP agent |
| hyperborea | Debian (RPi) | arm64 | Online, agent provisioning planned |
| hyperborea | Debian (RPi) | arm64 | Online, needs agent provisioning |
| svc | Debian | amd64 | Runs MCIAS, needs agent for public edge services |
| orion | NixOS | amd64 | Provisioned, offline for maintenance |
---
+1 -10
View File
@@ -15,11 +15,6 @@ All services are written in Go and follow shared
[engineering standards](engineering-standards.md). Full platform documentation
lives in [docs/metacircular.md](docs/metacircular.md).
> **2026-09-20 — V1/V2 are archived.** The platform was parked and is being
> redesigned as V3 (see `docs/v3/`). The source repos described below now live
> under `archive/v2/` (each still its own git repo, gitignored here). Paths in
> this document that name a component directory are relative to `archive/v2/`.
## Components
| Component | Purpose | Status |
@@ -30,9 +25,7 @@ lives in [docs/metacircular.md](docs/metacircular.md).
| **MC-Proxy** | Node ingress — TLS proxy and router. L4 passthrough or L7 terminating (per-route), PROXY protocol, firewall with rate limiting and GeoIP. | Implemented |
| **MCNS** | Networking — authoritative DNS for internal platform zones, upstream forwarding. | Implemented |
| **MCP** | Control plane — operator-driven deployment, service registry, data transfer, master/agent container lifecycle. | Implemented |
| **MCDoc** | Documentation server — renders markdown from Gitea, serves public docs. | Implemented |
| **MCQ** | Document review queue — push docs for review, MCP server for Claude Code integration. | Implemented |
| **MCDeploy** | Deployment CLI — tactical bridge tool, now deprecated and archived. Superseded by MCP. | Deprecated |
| **MCDoc** | Documentation server — renders markdown from Gitea, serves public docs. | In progress |
Shared library: **MCDSL** — standard library for all services (auth, db,
config, TLS server, CSRF, snapshots).
@@ -109,9 +102,7 @@ metacircular/
├── mcns/ DNS server
├── mcat/ Login policy tester
├── mcdsl/ Standard library (shared packages)
├── mcdeploy/ Deployment CLI (deprecated, archived)
├── mcdoc/ Documentation server
├── mcq/ Document review queue
├── ca/ PKI infrastructure (dev/test, not source code)
└── docs/ Platform-wide documentation
```
+60 -89
View File
@@ -1,45 +1,41 @@
# Metacircular Platform Status
Last updated: 2026-04-02
Last updated: 2026-03-28
## Platform Overview
Two nodes operational (**rift** + **svc**), with **orion** provisioned but
offline for maintenance. Core infrastructure services run as containers on
rift, fronted by MC-Proxy. Svc operates as an MCP edge node managing
mc-proxy routing only (no containers); MCIAS runs on svc separately as a
systemd service. Bootstrap phases 04 complete (MCIAS, Metacrypt, MC-Proxy,
MCR all operational). MCP is deployed and managing all platform containers
on rift, with multi-node capability (svc as edge node). MCNS is deployed on
rift, serving authoritative DNS. Platform evolution Phases AD complete
(automated port assignment, route registration, TLS cert provisioning, and
DNS registration). Phase E (multi-node expansion) is in planning, with v2
architecture in development.
One node operational (**rift**), running core infrastructure services as
containers fronted by MC-Proxy. MCIAS runs separately (not on rift).
Bootstrap phases 04 complete (MCIAS, Metacrypt, MC-Proxy, MCR all
operational). MCP is deployed and managing all platform containers. MCNS is
deployed on rift, serving authoritative DNS. Platform evolution Phases AD
complete (automated port assignment, route registration, TLS cert
provisioning, and DNS registration). Multi-node deployment is being planned
(Phase E).
## Service Status
| Service | Version | SDLC Phase | Deployed | Node |
|---------|---------|------------|----------|------|
| MCIAS | v1.10.5 | Maintenance | Yes | svc (systemd) |
| Metacrypt | v1.4.1 | Production | Yes | rift |
| MC-Proxy | v1.2.2 | Maintenance | Yes | rift |
| MCR | v1.3.2 | Production | Yes | rift |
| MCAT | v1.2.0 | Production | Yes | rift |
| MCDSL | v1.7.0 | Stable | N/A (library) | — |
| MCNS | v1.2.0 | Production | Yes | rift |
| MCDoc | v0.1.0 | Production | Yes | rift |
| MCQ | v0.4.2 | Production | Yes | rift |
| MCP | v0.9.0 | Production | Yes | rift |
| MCIAS | v1.9.0 | Maintenance | Yes | (separate) |
| Metacrypt | v1.3.1 | Production | Yes | rift |
| MC-Proxy | v1.2.1 | Maintenance | Yes | rift |
| MCR | v1.2.1 | Production | Yes | rift |
| MCAT | v1.1.1 | Complete | Unknown | — |
| MCDSL | v1.4.0 | Stable | N/A (library) | — |
| MCNS | v1.1.1 | Production | Yes | rift |
| MCP | v0.7.6 | Production | Yes | rift |
| MCDoc | v0.1.0 | Active dev | No | — |
## Service Details
### MCIAS — Identity and Access Service
- **Version:** v1.10.5 (client library: clients/go/v0.2.0)
- **Version:** v1.9.0 (client library: clients/go/v0.2.0)
- **Phase:** Maintenance. Phases 0-14 complete. Feature-complete with active
refinement.
- **Deployment:** Running in production on svc as a systemd service. All
other services authenticate against it.
- **Deployment:** Running in production. All other services authenticate
against it.
- **Recent work:** WebAuthn/FIDO2 passkeys, TOTP 2FA, service-context login
policies, Nix flake for CLI tools.
- **Artifacts:** systemd units (service + backup timer), install script,
@@ -47,7 +43,7 @@ architecture in development.
### Metacrypt — Cryptographic Service Engine
- **Version:** v1.4.1 (API v1.3.1, Web v1.4.1).
- **Version:** v1.3.1.
- **Phase:** Production. All four engine types implemented (CA, SSH CA, transit,
user-to-user). Active work on integration test coverage.
- **Deployment:** Running on rift as a container, fronted by MC-Proxy on
@@ -59,21 +55,19 @@ architecture in development.
### MC-Proxy — TLS Proxy and Router
- **Version:** v1.2.2.
- **Phase:** Maintenance. Stable and actively routing traffic on rift and svc.
- **Version:** v1.2.1.
- **Phase:** Maintenance. Stable and actively routing traffic on rift.
- **Deployment:** Running on rift. Fronts Metacrypt, MCR, and sgard on ports
443, 8443, and 9443. Prometheus metrics on 127.0.0.1:9091. Routes persisted
in SQLite and managed via gRPC API. Svc runs its own mc-proxy on :443 with
public-facing routes.
- **Recent work:** Route persistence (SQLite), idempotent AddRoute (upsert),
golangci-lint v2 compliance, module path migration to mc/ org.
in SQLite and managed via gRPC API.
- **Recent work:** MCR route additions, Nix flake, L7 backend cert handling,
Prometheus metrics, L7 policies.
- **Artifacts:** systemd units (service + backup timer), Docker Compose
(standard + rift), install and backup scripts, rift config.
### MCR — Container Registry
- **Version:** v1.3.2 (API v1.2.1, Web v1.3.2). All implementation phases
complete.
- **Version:** v1.2.1. All implementation phases complete.
- **Phase:** Production. Deployed on rift, serving container images.
- **Deployment:** Running on rift as two containers (mcr API + mcr-web),
fronted by MC-Proxy on ports 443 (web, L7), 8443 (API, L4), and
@@ -86,26 +80,26 @@ architecture in development.
### MCAT — Login Policy Tester
- **Version:** v1.2.0.
- **Phase:** Production. Deployed on rift as a container managed by MCP.
- **Deployment:** Running on rift. Lightweight tool for testing MCIAS login
policy rules.
- **Version:** v1.1.1.
- **Phase:** Complete. Diagnostic tool, not core infrastructure.
- **Deployment:** Available for ad-hoc use. Lightweight tool for testing
MCIAS login policy rules.
- **Recent work:** Migrated to mcdsl for auth, config, CSRF, and web.
- **Artifacts:** systemd unit, install script, example config.
### MCDSL — Standard Library
- **Version:** v1.7.0.
- **Version:** v1.4.0.
- **Phase:** Stable. All 9 packages implemented and tested. Being adopted
across the platform.
- **Deployment:** N/A (Go library, imported by other services).
- **Packages:** auth, db, config, httpserver, grpcserver, csrf, web, health,
archive.
- **Adoption:** All services except mcias on v1.7.0. mcias pending.
- **Adoption:** All services except mcias on v1.4.0. mcias pending.
### MCNS — Networking Service
- **Version:** v1.2.0.
- **Version:** v1.1.1.
- **Phase:** Production. Custom Go DNS server replacing CoreDNS precursor.
- **Deployment:** Running on rift as a container managed by MCP. Serves two
authoritative zones plus upstream forwarding. REST + gRPC APIs with MCIAS
@@ -115,75 +109,52 @@ architecture in development.
- **Artifacts:** Dockerfile, Docker Compose (rift), MCP service definition,
systemd units, install script, example config.
### MCP — Control Plane
- **Version:** v0.7.6.
- **Phase:** Production. Phases AD complete (automated port assignment, route
registration, TLS cert provisioning, DNS registration).
- **Deployment:** Running on rift. Agent as systemd service under `mcp` user
with rootless podman. Manages metacrypt, mc-proxy, mcr, and mcns containers.
- **Architecture:** Two components — `mcp` CLI (thin client on vade) and
`mcp-agent` (per-node daemon with SQLite registry, podman management,
monitoring with drift/flap detection). gRPC-only (no REST). 15 RPCs, 17+
CLI commands.
- **Recent work:** Phase C (automated TLS cert provisioning via Metacrypt CA),
Phase D (automated DNS registration via MCNS), undeploy command, logs
command, edit command, auto-login to MCR, system account auth model.
- **Artifacts:** systemd service (NixOS), TLS cert from Metacrypt, service
definition files, design docs.
### MCDoc — Documentation Server
- **Version:** v0.1.0.
- **Phase:** Production. Fetches and renders markdown documentation from Gitea.
- **Deployment:** Running on rift as a container, fronted by MC-Proxy on
port 443 (L7).
- **Recent work:** Initial implementation, Gitea content fetching, goldmark
rendering with syntax highlighting, webhook-driven refresh.
- **Artifacts:** Dockerfile, MCP service definition.
### MCQ — Document Review Queue
- **Version:** v0.4.2.
- **Phase:** Production. Document review queue with MCP server for Claude
integration.
- **Deployment:** Running on rift as a container managed by MCP.
- **Recent work:** Claude MCP server integration, document review workflow.
- **Artifacts:** Dockerfile, MCP service definition.
### MCP — Control Plane
- **Version:** v0.9.0 (agent on rift: v0.8.3-dirty, agent on svc: v0.9.0).
- **Phase:** Production. Phases AD complete. Multi-node capable with svc
operating as an edge node. V2 architecture in development, Phase E planning
underway.
- **Deployment:** Running on rift. Agent as systemd service under `mcp` user
with rootless podman. Manages metacrypt, mc-proxy, mcr, mcns, mcdoc, mcat,
mcq, and non-platform containers. Svc runs an MCP agent for edge mc-proxy
route management.
- **Architecture:** Two components — `mcp` CLI (thin client on vade) and
`mcp-agent` (per-node daemon with SQLite registry, podman management,
monitoring with drift/flap detection, route registration with mc-proxy,
automated TLS cert provisioning for L7 routes via Metacrypt CA, automated
DNS registration in MCNS). gRPC-only (no REST). 15 RPCs, 17+ CLI commands.
- **Recent work:** Phase C (automated TLS cert provisioning), Phase D
(automated DNS registration via MCNS), undeploy command, logs command,
edit command, auto-login to MCR, system account auth model, module path
migration, multi-node edge support (svc).
- **Artifacts:** systemd service (NixOS), TLS cert from Metacrypt, service
definition files, design docs.
- **Phase:** Active development.
- **Deployment:** Not yet deployed.
- **Description:** Documentation server — fetches markdown from Gitea, renders
HTML, serves public docs via mc-proxy. No MCIAS auth required.
## Node Inventory
| Node | Address (LAN) | Address (Tailscale) | Role |
|------|---------------|---------------------|------|
| rift | 192.168.88.181 | 100.95.252.120 | Infrastructure services |
| svc | — | 100.106.232.4 | Edge — public mc-proxy, MCIAS (systemd) |
| orion | 192.168.88.214 | — | Worker (provisioned, offline for maintenance) |
## Rift Port Map
Note: Services deployed via MCP receive dynamically assigned host ports
(1000060000). The ports below are for infrastructure services with static
assignments or well-known ports.
assignments.
| Port | Protocol | Services |
|------|----------|----------|
| 53 | DNS (LAN + Tailscale) | mcns |
| 443 | L7 (TLS termination) | metacrypt-web, mcr-web, mcdoc, mcat, kls |
| 443 | L7 (TLS termination) | metacrypt-web, mcr-web |
| 8080 | HTTP (all interfaces) | exod |
| 8443 | L4 (SNI passthrough) | metacrypt API, mcr API |
| 9090 | HTTP (all interfaces) | exod |
| 9443 | L4 (SNI passthrough) | metacrypt gRPC, mcr gRPC, sgard |
| 9091 | HTTP (loopback) | MC-Proxy Prometheus metrics |
Svc runs its own mc-proxy on :443 (L7) with public-facing routes:
kls.metacircular.net, mcq.metacircular.net, metacrypt.metacircular.net,
docs.metacircular.net, git.metacircular.net, git.wntrmute.dev.
Non-platform services also running on rift: **exod** (v0.1.0, ports
8080/9090), **sgardd** (v3.2.0, port 19473, fronted by MC-Proxy on 9443),
**kls** (v0.2.0).
Non-platform services also running on rift: **exod** (ports 8080/9090),
**sgardd** (port 19473, fronted by MC-Proxy on 9443).
-158
View File
@@ -1,158 +0,0 @@
# svc.metacircular.net — Phase 1 Work Log
Date: 2026-04-02
Purpose: Deploy mcp-agent to svc (edge node) for MCP v2 Phase 1.
## Changes Made
### 1. Created `mcp` system user
```
useradd --system --home-dir /srv/mcp --create-home --shell /usr/sbin/nologin mcp
usermod -aG mc-proxy mcp
```
- UID 992, GID 991
- Member of `mc-proxy` group for socket access
### 2. Created `/srv/mcp/` directory structure
```
/srv/mcp/
├── mcp-agent # binary (v0.8.3-1-gfa8ba6f, linux/amd64)
├── mcp-agent.toml # agent config
├── mcp.db # SQLite registry (created on first run)
└── certs/
├── cert.pem # TLS cert (SAN: IP:100.106.232.4, DNS:svc.svc.mcp.metacircular.net)
├── key.pem # TLS private key
└── ca.pem # Metacircular CA cert
```
- Owned by `mcp:mcp`, key file mode 0600
### 3. TLS certificate
- Issued from the Metacircular CA (`ca/ca.pem` + `ca/ca.key`)
- Subject: `CN=mcp-agent-svc`
- SANs: `IP:100.106.232.4`, `DNS:svc.svc.mcp.metacircular.net`
- Validity: 365 days
- Stored at `/srv/mcp/certs/{cert,key,ca}.pem`
### 4. Agent configuration
- File: `/srv/mcp/mcp-agent.toml`
- gRPC listen: `100.106.232.4:9555` (port 9444 in use by MCNS)
- MCIAS: `https://mcias.metacircular.net:8443`
- mc-proxy socket: `/srv/mc-proxy/mc-proxy.sock`
- Node name: `svc`
- Runtime: `podman` (not used on edge, but required by config)
### 5. systemd unit
- File: `/etc/systemd/system/mcp-agent.service`
- Runs as `mcp:mcp`
- Security hardened (NoNewPrivileges, ProtectSystem=strict, etc.)
- ReadWritePaths: `/srv/mcp`, `/srv/mc-proxy/mc-proxy.sock`
- Enabled and started
### 6. mc-proxy directory permissions
- Changed `/srv/mc-proxy/` from `drwx------` to `drwxr-x---` (group traversal)
- Changed `/srv/mc-proxy/mc-proxy.sock` from `srw-------` to `srw-rw----` (group read/write)
- Required for `mcp` user (in `mc-proxy` group) to access the socket
### 7. MCP CLI config update (on rift)
- Added svc node to `~/.config/mcp/mcp.toml`:
```toml
[[nodes]]
name = "svc"
address = "100.106.232.4:9555"
```
## Verification
```
$ mcp node list
NAME ADDRESS VERSION
rift 100.95.252.120:9444 v0.8.3-dirty
svc 100.106.232.4:9555 v0.8.3-1-gfa8ba6f
$ mcp route list -n svc
NODE: svc
mc-proxy v1.2.1-2-g82fce41-dirty
:443 routes=6
l7 git.wntrmute.dev → 127.0.0.1:3000
l7 kls.metacircular.net → 100.95.252.120:58080
l7 mcq.metacircular.net → 100.95.252.120:48080
l7 metacrypt.metacircular.net → 100.95.252.120:18080 (re-encrypt)
l7 docs.metacircular.net → 100.95.252.120:38080
l7 git.metacircular.net → 127.0.0.1:3000
```
## Agent Cert Reissue (2026-04-02)
Both agent certs reissued with comprehensive SANs:
**Rift agent** (`/srv/mcp/certs/cert.pem`):
- DNS: `rift.scylla-hammerhead.ts.net`, `mcp-agent.svc.mcp.metacircular.net`
- IP: `100.95.252.120`, `192.168.88.181`
**Svc agent** (`/srv/mcp/certs/cert.pem`):
- DNS: `svc.scylla-hammerhead.ts.net`, `svc.svc.mcp.metacircular.net`
- IP: `100.106.232.4`
Both agents upgraded to v0.10.0 (Phase 2 edge routing RPCs + v2 proto fields).
## MCP Master Deployment (2026-04-02)
**Binary**: `/srv/mcp-master/mcp-master` (v0.10.0) on rift
**Config**: `/srv/mcp-master/mcp-master.toml`
**Database**: `/srv/mcp-master/master.db`
**Certs**: `/srv/mcp-master/certs/{cert,key,ca}.pem`
- SAN: `rift.scylla-hammerhead.ts.net`, `mcp-master.svc.mcp.metacircular.net`, IP `100.95.252.120`
**Service token**: `/srv/mcp-master/mcias-token` (MCIAS identity: `mcp-master`, expires 2027-04-03)
**Listen**: `100.95.252.120:9555`
**Bootstrap nodes**: rift (master), svc (edge)
**Status**: Running via `doas` (ad-hoc). NixOS read-only /etc prevents
direct systemd unit creation — needs NixOS config update for persistent
service.
**Tested**:
- `mcp deploy mcq` → master places on rift, forwards to agent ✓
- `mcp undeploy mcq` → master forwards to agent, cleans up placement ✓
- `mcp ps` → fleet-wide status through agents ✓
- `mcp node list` → both nodes visible with versions ✓
## CLI Config Changes (vade)
Updated `~/.config/mcp/mcp.toml`:
- Added `[master]` section: `address = "rift.scylla-hammerhead.ts.net:9555"`
- All node addresses switched to Tailscale DNS names
- Added CA cert path
## Known Limitations
- ~~mc-proxy socket permissions will reset on restart~~ **FIXED**: mc-proxy
now creates the socket with 0660 (was 0600). Committed to mc-proxy master.
- Master runs ad-hoc via `doas` on rift. Needs NixOS systemd config for
persistent service (rift has read-only /etc).
- DNS registration not configured on master (MCNS config omitted for now).
- Edge routing not yet tested end-to-end through master (svc cert provisioning
not configured).
- The TLS cert was issued from the local CA directly, not via Metacrypt API.
Should be re-issued via Metacrypt once the agent has cert provisioning.
- Container runtime is set to `podman` but podman is not installed on svc
(Docker is). Edge agents don't run containers so this is benign.
- Metacrypt and MCNS integrations not configured (not needed for edge role).
## Edge Routing E2E Test (2026-04-02)
Full edge routing flow tested successfully through the master:
1. Added `public = true` route for `mcq.metacircular.net` to mcq service def
2. `mcp deploy mcq` → master placed on rift, deployed, registered DNS, set up edge route on svc
3. Svc agent provisioned TLS cert from Metacrypt (expires 2026-06-28)
4. mc-proxy route created: `mcq.metacircular.net → 100.95.252.120:8443` (re-encrypt)
5. Edge route persisted in both master DB and svc agent registry
**Fix required**: `RouteDef` in servicedef.go was missing `Public` field —
TOML `public = true` was silently dropped. Fixed in v0.10.2 along with
`Tier` field and relaxed `Node` validation.
## DNS Registration Working (2026-04-02)
Master registers Tailnet IPs in MCNS (not LAN IPs). Fix in v0.10.1
resolves Tailscale DNS names to IPs before passing to MCNS.
`mcq.svc.mcp.metacircular.net → 100.95.252.120` (Tailnet IP) ✓
+7 -12
View File
@@ -8,11 +8,12 @@ Go binaries built with `CGO_ENABLED=0` and installed via Nix flakes.
| Tool | Project | Purpose | Install target |
|------|---------|---------|---------------|
| `mcp` | mcp | Control plane CLI — deploy, status, lifecycle, file transfer | vade, orion |
| `mcp-agent` | mcp | Control plane agent — per-node container management daemon | rift, svc (systemd) |
| `mcp-agent` | mcp | Control plane agent — per-node container management daemon | rift (systemd) |
| `mciasctl` | mcias | MCIAS admin CLI — accounts, tokens, policies | vade, orion, rift |
| `mciasgrpcctl` | mcias | MCIAS gRPC debug CLI | vade, orion, rift |
| `mcproxyctl` | mc-proxy | MC-Proxy admin CLI — routes, firewall, status | vade, orion, rift |
| `mcrctl` | mcr | MCR admin CLI — repositories, policies, audit | vade, orion, rift |
| `mcdeploy` | mcdeploy | Deployment CLI — build, push, deploy (bridge tool) | vade |
### Server-only binaries (not installed as tools)
@@ -28,8 +29,6 @@ These run inside containers and are not installed on operator workstations:
| `mc-proxy` | mc-proxy | TLS proxy server |
| `mcns` | mcns | DNS server |
| `mcat` | mcat | Login policy tester web app |
| `mcdoc` | mcdoc | Documentation server |
| `mcq` | mcq | Document review queue |
## Installation
@@ -47,18 +46,15 @@ via `mcpkg.nix` in the NixOS configuration. Adding a tool:
- Build: `pkgs.buildGoModule` with `vendorHash = null` (vendored deps).
- ldflags: `-s -w -X main.version=${version}`.
- `subPackages`: list only the client binaries, not servers.
- `system`: `x86_64-linux` for rift, svc, and orion; `aarch64-linux`
for hyperborea. Flakes that target the full fleet should support both.
- `system`: `x86_64-linux` (all machines are x86_64).
### MCP agent
The `mcp-agent` is a special case: it runs as a systemd service on
managed nodes (not as a container, since it manages containers). Its
flake exposes both `mcp` (client) and `mcp-agent` (server). Phase E is
moving the agent binary to `/srv/mcp/mcp-agent` on all nodes — NixOS
`ExecStart` will point there instead of a nix store path, and Debian
nodes use the same layout. svc already follows this convention. See
`docs/phase-e-plan.md` for details.
flake exposes both `mcp` (client) and `mcp-agent` (server). The NixOS
config in `configs/mcp.nix` references the Nix package path for the
agent binary.
## Flake status
@@ -67,10 +63,9 @@ nodes use the same layout. svc already follows this convention. See
| mcias | Yes | mciasctl, mciasgrpcctl | Yes | |
| mc-proxy | Yes | mcproxyctl | Yes | |
| mcr | Yes | mcrctl | Yes | |
| mcdeploy | Yes | mcdeploy | Yes | |
| mcp | Yes | mcp, mcp-agent | Yes | Agent also used by mcp.nix systemd unit |
| mcns | No | — | No | Server-only, no client tool yet |
| metacrypt | No | — | No | Server-only, no client tool yet |
| mcat | No | — | No | Server-only, no client tool yet |
| mcdoc | No | — | No | Server-only, deployed as container |
| mcq | No | — | No | Server-only, document review queue |
| mcdsl | No | — | No | Library, no binaries |
File diff suppressed because it is too large Load Diff
-344
View File
@@ -1,344 +0,0 @@
# Disaster Recovery: Bootstrap from Zero
This document covers recovering the Metacircular platform when all
services on rift are down and no containers are running. It assumes:
- The machine boots and NixOS is functional
- The mcp-agent systemd service starts automatically
- Tailscale is configured and connects on boot
- Service data directories (`/srv/<service>/`) are intact on disk
- Container images are cached in podman's overlay storage
If images are NOT cached (fresh machine, disk wipe), see the
"Cold Start" section at the end.
## Prerequisites
Before starting recovery, verify:
```bash
# 1. Machine is up
hostname # should print "rift"
# 2. Tailscale is connected
tailscale status --self
# Should show the Tailnet IP (100.95.252.120)
# 3. The mcp user exists
id mcp
# Should show uid=850(mcp) gid=850(mcp)
# 4. The agent is running
systemctl status mcp-agent
# Should be active
# 5. Images are cached
su -s /bin/sh mcp -c "XDG_RUNTIME_DIR=/run/user/850 HOME=/srv/mcp podman images" | wc -l
# Should be > 0
```
If Tailscale is not running: `doas systemctl start tailscaled && doas tailscale up`
If the agent is not running: check `/srv/mcp/mcp-agent` exists and
`/srv/mcp/mcp-agent.toml` is correct, then `doas systemctl restart mcp-agent`.
## Recovery Order
Services must be started in dependency order. Each stage must be
healthy before the next starts.
```
Stage 1 (Foundation): MCNS → DNS works
Stage 2 (Core): mc-proxy, MCR, Metacrypt → routing + images + certs
Stage 3 (Management): mcp-master → orchestration
Stage 4 (Services): mcq, mcdoc, mcat, kls, sgard, exo → applications
```
## Manual Recovery Commands
All commands run as the mcp user. Use this shell prefix:
```bash
# Set up the environment
export PODMAN_CMD='doas sh -c "cd /srv/mcp && XDG_RUNTIME_DIR=/run/user/850 HOME=/srv/mcp su -s /bin/sh mcp -c"'
# Or SSH as mcp directly (if SSH login is enabled):
ssh mcp@rift
```
For brevity, commands below show the `podman run` portion only. Prefix
with the environment setup above.
### Stage 1: MCNS (DNS)
MCNS must start first. Without it, no hostname resolution works.
```bash
podman run -d --name mcns --restart unless-stopped \
-p 192.168.88.181:53:53/tcp \
-p 192.168.88.181:53:53/udp \
-p 100.95.252.120:53:53/tcp \
-p 100.95.252.120:53:53/udp \
-p 127.0.0.1:38443:8443 \
-v /srv/mcns:/srv/mcns \
mcr.svc.mcp.metacircular.net:8443/mcns:v1.2.0 \
server --config /srv/mcns/mcns.toml
```
**Verify:**
```bash
dig @192.168.88.181 google.com +short
# Should return an IP address
dig @192.168.88.181 mcq.svc.mcp.metacircular.net +short
# Should return a Tailnet IP
```
**Note:** MCNS binds to specific IPs, not `0.0.0.0`, because
systemd-resolved holds port 53 on localhost. The explicit bindings
avoid the conflict.
### Stage 2: Core Infrastructure
#### mc-proxy (TLS routing)
```bash
podman run -d --name mc-proxy --restart unless-stopped \
--network host \
-v /srv/mc-proxy:/srv/mc-proxy \
mcr.svc.mcp.metacircular.net:8443/mc-proxy:v1.2.2 \
server --config /srv/mc-proxy/mc-proxy.toml
```
**Verify:** `curl -sk https://localhost:443/ 2>&1 | head -1`
(should get a response, even if 404)
#### MCR (Container Registry)
```bash
# API server
podman run -d --name mcr-api --restart unless-stopped \
-v /srv/mcr:/srv/mcr \
-p 127.0.0.1:28443:8443 \
-p 127.0.0.1:29443:9443 \
mcr.svc.mcp.metacircular.net:8443/mcr:v1.2.1 \
server --config /srv/mcr/mcr.toml
# Web UI
podman run -d --name mcr-web --restart unless-stopped \
--user 0:0 \
-v /srv/mcr:/srv/mcr \
-p 127.0.0.1:28080:8080 \
mcr.svc.mcp.metacircular.net:8443/mcr-web:v1.3.2 \
server --config /srv/mcr/mcr.toml
```
**If MCR fails with "chmod" or "readonly database":**
```bash
podman stop mcr-api
rm -f /srv/mcr/mcr.db /srv/mcr/mcr.db-wal /srv/mcr/mcr.db-shm
podman start mcr-api
```
This recreates the database empty. Image blobs in `/srv/mcr/layers/`
are preserved but tag metadata is lost. Re-push images to rebuild the
registry.
#### Metacrypt (PKI / Secrets)
```bash
# API server
podman run -d --name metacrypt-api --restart unless-stopped \
-v /srv/metacrypt:/srv/metacrypt \
-p 127.0.0.1:18443:8443 \
-p 127.0.0.1:19443:9443 \
mcr.svc.mcp.metacircular.net:8443/metacrypt:v1.3.1 \
server --config /srv/metacrypt/metacrypt.toml
# Web UI
podman run -d --name metacrypt-web --restart unless-stopped \
-v /srv/metacrypt:/srv/metacrypt \
-p 127.0.0.1:18080:8080 \
mcr.svc.mcp.metacircular.net:8443/metacrypt-web:v1.4.1 \
--config /srv/metacrypt/metacrypt.toml
```
**If Metacrypt fails with "chmod" or "readonly database":**
Same fix as MCR — delete the database files. **Warning:** this loses
all encrypted secrets, issued certs tracking, and CA state. The CA
key itself is in the sealed vault (password-protected), not in SQLite.
### Stage 3: MCP Master
```bash
podman run -d --name mcp-master --restart unless-stopped \
--network host \
-v /srv/mcp-master:/srv/mcp-master \
mcr.svc.mcp.metacircular.net:8443/mcp-master:v0.10.3 \
server --config /srv/mcp-master/mcp-master.toml
```
**Verify:**
```bash
# From vade (operator workstation):
mcp node list
# Should show rift, svc, orion
```
### Stage 4: Application Services
Once the master is running, deploy applications through MCP:
```bash
mcp deploy mcq --direct
mcp deploy mcdoc --direct
mcp deploy mcat --direct
mcp deploy kls --direct
```
Or start them manually:
```bash
# MCQ
podman run -d --name mcq --restart unless-stopped \
-v /srv/mcq:/srv/mcq \
-p 127.0.0.1:48080:8080 -p 100.95.252.120:48080:8080 \
mcr.svc.mcp.metacircular.net:8443/mcq:v0.4.2 \
server --config /srv/mcq/mcq.toml
# MCDoc
podman run -d --name mcdoc --restart unless-stopped \
-v /srv/mcdoc:/srv/mcdoc \
-p 127.0.0.1:38080:8080 \
mcr.svc.mcp.metacircular.net:8443/mcdoc:v0.1.0 \
server --config /srv/mcdoc/mcdoc.toml
# MCAT
podman run -d --name mcat --restart unless-stopped \
-v /srv/mcat:/srv/mcat \
-p 127.0.0.1:48116:8443 \
mcr.svc.mcp.metacircular.net:8443/mcat:v1.2.0 \
server --config /srv/mcat/mcat.toml
# KLS
podman run -d --name kls --restart unless-stopped \
-v /srv/kls:/srv/kls \
-p 127.0.0.1:58080:8080 -p 100.95.252.120:58080:8080 \
mcr.svc.mcp.metacircular.net:8443/kls:v0.2.0 \
-f /srv/kls/kls.conf
# Sgard
podman run -d --name sgardd --restart unless-stopped \
-v /srv/sgard:/srv/sgard \
-p 127.0.0.1:19473:9473 \
mcr.svc.mcp.metacircular.net:8443/sgardd:v3.2.0 \
--repo /srv/sgard --authorized-keys /srv/sgard/authorized_keys \
--tls-cert /srv/sgard/certs/sgard.pem --tls-key /srv/sgard/certs/sgard.key
```
## Verification Checklist
After all services are running:
```bash
# Fleet status
mcp ps
# All services should show "running"
# DNS
dig @192.168.88.181 google.com +short
dig @192.168.88.181 mcq.svc.mcp.metacircular.net +short
# MCIAS (runs on svc, should be unaffected by rift outage)
curl -sk https://mcias.metacircular.net:8443/v1/health
# MCR
curl -sk https://mcr.svc.mcp.metacircular.net:8443/v2/
# Metacrypt
curl -sk https://metacrypt.svc.mcp.metacircular.net:8443/v1/health
# Public routes via svc
curl -sk https://mcq.metacircular.net/
curl -sk https://docs.metacircular.net/
```
## Common Errors
### "chmod: operation not permitted"
modernc.org/sqlite calls `fchmod()` on database files. This is denied
inside rootless podman user namespaces. Fix:
```bash
# Delete the database and let the service recreate it
podman stop <container>
rm -f /srv/<service>/<service>.db*
podman start <container>
```
The `fchmod` error will still appear in logs as a warning but is
non-fatal for newly created databases.
### "address already in use" on port 53
systemd-resolved holds port 53 on localhost. MCNS must bind to
specific IPs, not `0.0.0.0:53`. Use explicit port bindings:
`-p 192.168.88.181:53:53 -p 100.95.252.120:53:53`
### "connection refused" to MCR
MCR is down. Images are cached locally — you can start services that
use cached images without MCR. MCR itself starts from its cached
image.
### Agent shows "error" for all nodes
Check:
1. Tailscale is running on both the CLI machine and the target node
2. The agent is listening: `ss -tlnp | grep 9444`
3. The CLI config has the correct addresses
4. TLS certs have the right SANs for the Tailnet IP
### "podman: executable file not found"
This warning appears for svc (which uses Docker, not podman). It's
benign — svc is an edge node that doesn't run containers.
## Cold Start (No Cached Images)
If the machine was wiped and no images are cached:
1. **MCIAS** runs on svc (Docker container), not rift. It should be
unaffected by a rift failure. Verify: `ssh svc.metacircular.net
"docker ps | grep mcias"`.
2. **Pre-stage images** by pulling from a backup or building locally:
```bash
# On vade (operator workstation), build and push to a temp location
cd ~/src/metacircular/mcns && make docker
podman save mcr.svc.mcp.metacircular.net:8443/mcns:v1.2.0 | \
ssh rift "podman load"
```
Repeat for each service.
3. Alternatively, if another node has MCR access, push images there
first, then pull from the running MCR instance.
## Service Reference
Quick reference for all services, their images, and critical flags:
| Service | Image | Network | Key Ports | Config Path |
|---------|-------|---------|-----------|-------------|
| mcns | mcns:v1.2.0 | bridge | 53/tcp+udp, 38443→8443 | /srv/mcns/mcns.toml |
| mc-proxy | mc-proxy:v1.2.2 | host | 443, 8443, 9443 | /srv/mc-proxy/mc-proxy.toml |
| mcr (api) | mcr:v1.2.1 | bridge | 28443→8443, 29443→9443 | /srv/mcr/mcr.toml |
| mcr (web) | mcr-web:v1.3.2 | bridge | 28080→8080 | /srv/mcr/mcr.toml |
| metacrypt (api) | metacrypt:v1.3.1 | bridge | 18443→8443, 19443→9443 | /srv/metacrypt/metacrypt.toml |
| metacrypt (web) | metacrypt-web:v1.4.1 | bridge | 18080→8080 | /srv/metacrypt/metacrypt.toml |
| mcp-master | mcp-master:v0.10.3 | host | 9555 | /srv/mcp-master/mcp-master.toml |
| mcq | mcq:v0.4.2 | bridge | 48080→8080 | /srv/mcq/mcq.toml |
| mcdoc | mcdoc:v0.1.0 | bridge | 38080→8080 | /srv/mcdoc/mcdoc.toml |
| mcat | mcat:v1.2.0 | bridge | 48116→8443 | /srv/mcat/mcat.toml |
| kls | kls:v0.2.0 | bridge | 58080→8080 | /srv/kls/kls.conf |
| sgard | sgardd:v3.2.0 | bridge | 19473→9473 | (flags, see above) |
All images are prefixed with `mcr.svc.mcp.metacircular.net:8443/`.
-244
View File
@@ -1,244 +0,0 @@
# Hypervisor-Based Service Isolation -- Design Notes
> **Status**: Brainstorming / future direction. This document is NOT
> an active work item. Agents should ignore this document unless
> specifically asked to consider it.
## Context
The metacircular platform runs Go services as rootless podman
containers orchestrated by MCP. This is a pragmatic execution of ideas
originally explored in a series of 2015 papers on security kernels,
environment isolation, and unikernels (see References). Those papers
describe a richer model than what containers provide: hardware-enforced
isolation, mandatory inter-environment communication mediation, and
capability-based access control. This document explores bridging the
gap by running services as unikernel VMs (specifically Nanos) on the
MCP control plane.
## Motivation: What Containers Don't Give Us
The current platform has the W7 security kernel's three properties --
isolated environments, inter-environment communication (IEC), and
access mediation -- but implemented cooperatively rather than enforced:
| W7 Property | Metacircular Today | Enforcement |
|---|---|---|
| Isolated environments | Rootless podman (namespaces/cgroups) | OS-cooperative -- shared kernel, escape CVEs exist |
| IEC | gRPC/TLS through mc-proxy | Application-cooperative -- services *choose* to route through mc-proxy |
| Access mediation | MCIAS tokens + per-service policies | Application-level -- services check tokens voluntarily |
The topology is right. The enforcement mechanism is weak. Containers
share a kernel, and any service could bypass mc-proxy to reach the
Tailnet directly.
## What Unikernels Buy Us
### Hardware-Enforced Isolation
Each service runs in its own VM with its own kernel. There is no
shared kernel to escape from. The security boundary is the hypervisor
(KVM), not Linux namespaces. This is the W7 "isolated environments"
model enforced by hardware, not convention.
### Mandatory IEC
This is the subtle but powerful part. A container on the Tailnet can
talk to anything. A unikernel VM with no direct network interface --
only a virtio-net device connected to a host-only bridge that the
agent controls -- cannot bypass the mediation layer. If the agent is
the only Tailnet citizen on the node and VMs can only reach the
agent's bridge, then mc-proxy stops being a routing convenience and
becomes the IEC mechanism. Communication between environments is
mediated by design, not by trust.
### Reduced TCB
Container TCB: Linux kernel + podman runtime + container image (often
a full distro). Unikernel TCB: KVM + Nanos runtime + the Go binary.
No shell, no package manager, no multi-user, no unnecessary syscalls.
## The Agent as Security Kernel
The MCP agent is already structurally positioned to be the W7 security
kernel for its node. It manages environment lifecycle, controls the
IEC layer (mc-proxy routes), provisions credentials (Metacrypt certs),
and reports to a central authority (master).
With unikernels, this role is formalized. The agent becomes the only
entity with host access. Services exist in VMs that can only
communicate through agent-controlled channels:
- **Network access**: virtio-net bridge under agent control; the agent
decides what each VM can reach.
- **Storage access**: 9p/virtio-fs mounts; the agent controls what
each VM sees on disk.
- **Credential access**: the agent provisions certs into the VM's
filesystem before boot.
- **Identity**: the agent attests to the master what image hash is
running in each VM.
## Why This Is Feasible for Metacircular
Several properties of the existing platform make this tractable:
- **Go + CGO_ENABLED=0**: Every service already produces a static ELF
binary. Nanos needs exactly this. The `ops` tool packages them with
minimal friction.
- **Single-process services**: Each service is one Go binary -- no
sidecars, no shell scripts, no multi-process orchestration. That is
the unikernel sweet spot.
- **Single-operator trust domain**: No multi-tenant capability
delegation or federated attestation needed. The agent is the
security kernel for its node; the master is the coordination point.
- **mc-proxy already mediates traffic**: The routing mesh is already
in place. Making it mandatory (rather than optional) for unikernel
VMs is an incremental change, not a new system.
## Design Sketch
### Runtime Abstraction
The agent gains a `Runtime` interface. Podman is one implementation;
QEMU/KVM is another. Service definitions gain a `runtime` field:
```toml
name = "mcq"
runtime = "unikernel" # or "container" (default)
tier = "worker"
```
Both runtimes coexist. Services can be converted incrementally.
### Networking: Host-Only Bridge
Each unikernel VM gets a virtio-net device on a host-only bridge. The
agent runs on the bridge and controls forwarding. VMs cannot reach the
Tailnet directly. All external communication flows through mc-proxy on
the host.
This is structurally similar to how rootless podman already works
(container ports are localhost-only, mc-proxy routes to them), but
with the enforcement moved from convention to network topology.
### Storage: 9p Passthrough
Unikernel VMs mount `/srv/<service>/` via QEMU's `-virtfs` 9p
passthrough. Writes go directly to the host filesystem. This makes
snapshots work the same way as containers -- the agent tars the host
directory.
### Image Building
Two options (not mutually exclusive):
1. **Build on agent**: Agent extracts the ELF binary from the OCI
image (pulled from MCR) and runs `ops build` locally.
2. **Store unikernel images in MCR**: OCI supports arbitrary media
types. Unikernel `.img` files could be stored as OCI artifacts.
Option 1 is simpler to start with. Option 2 is cleaner long-term.
### Image Attestation
Before booting a unikernel, the agent hashes the image and reports it
to the master. The master compares against expected hashes from the
service definition. This is software attestation -- not TPM-based, but
it closes the "is this what I deployed?" question. It is a stepping
stone toward measured boot with hardware TPM.
### Snapshot Constraints
Unikernels have no shell. The `cli` and `exec:` snapshot methods
don't work. Only `grpc` snapshots are viable for unikernel services
(the service implements the standard `SnapshotService` RPC). The
default snapshot method (tar config/db/certs from the host-side 9p
mount) works unchanged since the agent tars the host directory, not
the VM filesystem.
### Debugging
No `podman exec`, no shell. Debugging relies on:
- Serial console output from QEMU
- gRPC health/status endpoints
- Structured logging to a file on the 9p mount
- The agent can snapshot and inspect VM state
This is a real loss of convenience. It is the price of proper
isolation -- as noted in the 2015 hypervisor paper, "the nature of
debugging means that isolation is broken."
## Difficulty Assessment
| Aspect | Difficulty | Notes |
|---|---|---|
| Building unikernel images from Go binaries | Easy | Already static ELF, `ops` handles it |
| QEMU lifecycle management in agent | Medium | Replace podman calls with qemu-system calls |
| Networking (host-only bridge + mc-proxy) | Medium | Similar to rootless podman model |
| Persistent storage via 9p | Medium | Well-supported in QEMU, maps to existing `/srv/` layout |
| Snapshots | Medium | `grpc` method works; `cli`/`exec` don't |
| Image attestation | Medium-Low | SHA-256 of image before boot |
| mc-proxy integration | Low | Just needs a reachable IP:port |
| Debugging/observability | Annoying | Loss of exec/shell access |
The minimum meaningful change is the runtime abstraction + isolated
networking together. Running a unikernel with full Tailnet access is
just a heavier container with worse debugging. The isolation properties
only kick in when the agent mediates all communication.
## Progression Path
1. **Runtime abstraction in the agent.** `Runtime` interface with
podman and qemu implementations. Service definitions gain a
`runtime` field. Both coexist.
2. **Isolated networking for unikernel VMs.** Host-only bridge per
node, agent controls forwarding. mc-proxy becomes the mandatory
IEC layer for unikernel services.
3. **Image attestation.** Agent hashes images before boot, reports to
master. Master compares against expected values.
4. **Capability tokens (longer-term).** MCIAS issues operation-scoped
tokens instead of identity tokens. The agent's mediation layer
enforces them at the network boundary. This is independent of
unikernels but synergizes with mandatory mediation.
## Open Questions
- **Tailscale integration**: Should unikernel VMs ever be first-class
Tailnet citizens (via tsnet compiled into the binary), or should the
agent always mediate? Mandatory mediation is more secure but means
the agent is on the critical path for all traffic.
- **Resource limits**: QEMU VMs need explicit memory and CPU
allocation. The current container model doesn't declare resource
requirements. Unikernels would force this.
- **Mixed fleet**: During transition, some services run as containers
and some as unikernels. mc-proxy routes to both. Does the master
need to know the runtime type for placement decisions?
- **ARM support**: Nanos supports aarch64 but the QEMU/KVM story on
Raspberry Pi (no KVM on all models) may limit unikernels to amd64
nodes.
## References
- Rees, J. "A Security Kernel Based on the Lambda Calculus" (W7
security kernel model -- isolated environments, IEC, access
mediation)
- "Containers, isolation, and operating systems for network spaces"
(2015) -- argues the OS must provide a security kernel; unikernels
as viable isolation mechanism
- "A hypervisor for the modern age" (2015) -- problem statement for a
hypervisor providing proper isolation, IEC, and access mediation
with a programmatic administrative interface
- "A content-addressable data store with object capabilities" (Nebula,
2015) -- capability-based access control model
- MCP v2 Architecture (`docs/architecture-v2.md`) -- current platform
design this document builds on
-852
View File
@@ -1,852 +0,0 @@
# Unikernel Migration Plan
> **Status**: Detailed work plan. Not an active work item. Agents
> should ignore this document unless specifically asked to consider it.
>
> **Prerequisite**: MCP v2 phase 6 complete -- master running, agents
> on all nodes, edge routing, snapshots, and migration all operational.
## Starting Point
The MCP agent already has a `runtime.Runtime` interface
(`mcp/internal/runtime/runtime.go`) with methods for Pull, Run, Stop,
Remove, Inspect, List, Build, Push, ImageExists, and Logs. The only
implementation is `Podman` (`mcp/internal/runtime/podman.go`). The
agent struct holds `Runtime runtime.Runtime` and all lifecycle
operations (deploy, stop, start, undeploy, status) go through this
interface.
This means the runtime abstraction layer is already in place. The
migration is primarily: implement a QEMU/Nanos backend for the
existing interface, add isolated networking, and extend service
definitions with runtime-specific fields.
## Terminology
| Term | Meaning |
|------|---------|
| **VM** | A QEMU/KVM virtual machine running a Nanos unikernel |
| **bridge** | A Linux bridge device (`mcp-br0`) on the host for VM networking |
| **TAP** | A TAP device attached to the bridge, one per VM |
| **9p mount** | QEMU's `-virtfs` passthrough for host directory access |
| **ops** | The Nanos toolchain CLI for building unikernel images |
---
## Phase 1: QEMU Runtime Implementation
**Goal**: A second `runtime.Runtime` implementation that can start and
stop Nanos unikernel VMs with basic networking. No isolation
enforcement yet -- VMs get host-forwarded ports like containers do.
### 1.1 NixOS Host Prerequisites
Add QEMU/KVM packages to the NixOS configuration on rift and orion.
svc (Debian) gets equivalent packages via apt.
Required on all nodes that will run unikernels:
- `qemu` (specifically `qemu-system-x86_64`)
- `ops` CLI (Nanos toolchain) -- install from GitHub release or build
from source
- KVM access: the `mcp` user needs `/dev/kvm` access. On NixOS, add
the user to the `kvm` group. On Debian, same.
- `bridge-utils` or `iproute2` for bridge management (Phase 2)
Verify KVM works: `qemu-system-x86_64 -enable-kvm -nographic
-no-reboot` should boot and exit.
**Deliverable**: All amd64 nodes can run QEMU with KVM acceleration.
RPi nodes (arm64, no KVM) are excluded from unikernel support.
### 1.2 Image Building Pipeline
The agent needs to produce a Nanos `.img` file from a Go binary. Two
paths, implemented in order:
**1.2a -- Local build from OCI image (initial approach)**
The agent already pulls OCI images via `Runtime.Pull()`. For
unikernels:
1. Pull the OCI image from MCR (reuse existing podman pull or use
`skopeo copy` to a local directory).
2. Extract the ELF binary from the image. Convention: the binary is at
`/usr/local/bin/<service>` in the image (same path the Dockerfiles
use).
3. Run `ops build <binary> -c <config.json>` to produce a `.img` file.
4. Store the image at `/srv/mcp/images/<service>-<component>.img`.
The `ops` config JSON specifies:
```json
{
"Args": ["server", "--config", "/srv/mcq/mcq.toml"],
"Dirs": ["srv"],
"Mounts": {
"/srv/<service>": "/srv/<service>"
},
"ManifestPassthrough": {
"mem": "256m",
"smp": 1
}
}
```
**1.2b -- Pre-built unikernel images in MCR (later)**
Store `.img` files as OCI artifacts in MCR with a distinct media type
(`application/vnd.metacircular.unikernel.nanos.v1`). The agent pulls
the artifact and writes it directly to
`/srv/mcp/images/<service>-<component>.img`. This skips the
extract-and-build step and ensures the deployed image is identical to
what was built.
MCR already stores OCI artifacts; this requires adding the media type
to MCR's accepted list and adding an `mcp build --unikernel` command
that builds the image locally and pushes it.
**Deliverable**: Agent can produce a bootable Nanos image from an
existing OCI container image.
### 1.3 QEMU Runtime Type
Implement `QEMURuntime` satisfying `runtime.Runtime`:
```go
type QEMURuntime struct {
imageDir string // /srv/mcp/images/
stateDir string // /srv/mcp/vm-state/
opsPath string // path to ops binary
qemuPath string // path to qemu-system-x86_64
logger *slog.Logger
mu sync.Mutex
vms map[string]*vmState // name → running VM state
}
type vmState struct {
pid int
qmpSocket string // QMP control socket
serial string // serial console log path
ip string // VM IP on bridge (Phase 2)
ports map[int]int // guest port → host port
}
```
**Method mapping:**
| Runtime Method | QEMU Implementation |
|---|---|
| `Pull(image)` | Pull OCI image, extract ELF, run `ops build`, store `.img` |
| `Run(spec)` | Start `qemu-system-x86_64` with KVM, virtio-net, 9p mounts, QMP socket |
| `Stop(name)` | Send `system_powerdown` via QMP, wait 10s, then SIGKILL |
| `Remove(name)` | Kill process if running, remove state files |
| `Inspect(name)` | Check process liveness + read QMP status |
| `List()` | Enumerate `/srv/mcp/vm-state/*/qemu.pid`, check liveness |
| `Build(...)` | Not applicable for unikernels (image built during Pull) |
| `Push(...)` | Not applicable (future: push `.img` to MCR as OCI artifact) |
| `ImageExists(image)` | Check if `.img` file exists in imageDir |
| `Logs(name)` | Read serial console log file |
**QEMU invocation** (Phase 1 -- user-mode networking with port
forwards, no bridge yet):
```
qemu-system-x86_64 \
-enable-kvm \
-m 256 \
-smp 1 \
-nographic \
-serial file:/srv/mcp/vm-state/<name>/console.log \
-qmp unix:/srv/mcp/vm-state/<name>/qmp.sock,server,nowait \
-drive file=/srv/mcp/images/<name>.img,format=raw,if=virtio \
-virtfs local,path=/srv/<service>,mount_tag=srvdata,security_model=mapped-xattr,id=srvdata \
-device virtio-net-pci,netdev=net0 \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:<host_port>-:<guest_port>
```
This gives user-mode networking with port forwards to localhost --
functionally identical to how rootless podman works. mc-proxy routes
to `127.0.0.1:<host_port>` the same way it does for containers.
**Deliverable**: A `QEMURuntime` that passes the same interface as
`Podman`. Agent can deploy, stop, inspect, and undeploy unikernel
services using QEMU user-mode networking.
### 1.4 Service Definition Changes
Add `runtime` field to service definitions, proto specs, and registry
schema.
**TOML** (`servicedef.go`):
```toml
name = "mcq"
runtime = "unikernel"
tier = "worker"
active = true
[[components]]
name = "mcq"
image = "mcr.svc.mcp.metacircular.net:8443/mcq:v0.4.0"
memory = 256 # MB, required for unikernels
vcpus = 1 # default 1
volumes = ["/srv/mcq:/srv/mcq"]
cmd = ["server", "--config", "/srv/mcq/mcq.toml"]
```
**Proto** (`mcp.proto`):
```protobuf
message ServiceSpec {
string name = 1;
bool active = 2;
repeated ComponentSpec components = 3;
string comment = 4;
string runtime = 5; // "container" (default) or "unikernel"
}
message ComponentSpec {
// ... existing fields ...
int32 memory_mb = 11; // required for unikernel runtime
int32 vcpus = 12; // default 1
}
```
**Registry schema** (new migration):
```sql
ALTER TABLE components ADD COLUMN runtime TEXT NOT NULL DEFAULT 'container';
ALTER TABLE components ADD COLUMN memory_mb INTEGER NOT NULL DEFAULT 0;
ALTER TABLE components ADD COLUMN vcpus INTEGER NOT NULL DEFAULT 1;
```
**Agent runtime selection**: In `agent.go`, the agent holds both
runtimes:
```go
type Agent struct {
// ... existing fields ...
ContainerRuntime runtime.Runtime // podman
UnikernelRuntime runtime.Runtime // qemu (nil if not configured)
}
func (a *Agent) runtimeFor(comp *registry.Component) runtime.Runtime {
if comp.Runtime == "unikernel" {
return a.UnikernelRuntime
}
return a.ContainerRuntime
}
```
All lifecycle operations call `a.runtimeFor(comp)` instead of
`a.Runtime` directly.
**Validation rules**:
- `runtime = "unikernel"` requires `memory_mb > 0`.
- `runtime = "unikernel"` requires the node to have KVM
(`/dev/kvm` exists). Agent rejects deploys on nodes without KVM.
- `runtime = "unikernel"` is incompatible with `exec:` and `cli`
snapshot methods. Validation rejects these combinations.
**Deliverable**: Service definitions can declare `runtime =
"unikernel"`. The agent selects the correct runtime per component.
Container services are completely unaffected.
### 1.5 Resource Tracking
The agent needs to track allocated VM resources to avoid overcommit.
The v2 heartbeat already reports CPU, memory, and disk. Add tracking
of allocated-to-VMs resources:
```go
type ResourceTracker struct {
mu sync.Mutex
totalMemMB int64 // from /proc/meminfo
totalCPUs int32 // from runtime.NumCPU()
allocMemMB int64 // sum of running VM memory_mb
allocCPUs int32 // sum of running VM vcpus
}
func (r *ResourceTracker) CanFit(memMB int64, vcpus int32) bool
func (r *ResourceTracker) Allocate(memMB int64, vcpus int32)
func (r *ResourceTracker) Release(memMB int64, vcpus int32)
```
The master's placement algorithm gains a resource check: before
placing a unikernel service on a node, verify the node has enough
unallocated memory and CPUs. Container services continue to use
container-count placement.
**Deliverable**: Agent tracks VM resource allocation. Master rejects
placements that would overcommit a node.
### 1.6 Phase 1 Validation
Deploy a test service (a minimal Go HTTP server, not a real platform
service) as a unikernel:
1. Build a trivial Go binary that serves HTTP on port 8080.
2. Package it as an OCI image, push to MCR.
3. Write a service definition with `runtime = "unikernel"`.
4. `mcp deploy test-unikernel` -- verify it starts, mc-proxy routes
to it, health checks pass.
5. `mcp undeploy test-unikernel` -- verify clean shutdown.
6. Verify container services are completely unaffected.
**Phase 1 complete when**: A unikernel service can be deployed,
health-checked, and undeployed through the normal `mcp deploy`/
`mcp undeploy` flow, alongside running container services.
---
## Phase 2: Isolated Networking
**Goal**: Replace QEMU user-mode networking with a host-only bridge.
VMs can only communicate through mc-proxy. This is the phase that
delivers the security properties -- without it, unikernels are just
heavier containers.
### 2.1 Bridge Setup
Create a persistent Linux bridge on each unikernel-capable node:
**NixOS** (`networking.bridges` in NixOS config):
```nix
networking.bridges.mcp-br0.interfaces = [];
networking.interfaces.mcp-br0.ipv4.addresses = [{
address = "10.99.0.1";
prefixLength = 24;
}];
```
**Debian** (svc -- if svc ever runs unikernels, which is unlikely
given its edge role, but document for completeness):
```
# /etc/network/interfaces.d/mcp-br0
auto mcp-br0
iface mcp-br0 inet static
address 10.99.0.1/24
bridge_ports none
bridge_stp off
```
The bridge uses the `10.99.0.0/24` subnet. This is a host-only
network -- no default route, no NAT to the internet or Tailnet. VMs
can only reach `10.99.0.1` (the agent/mc-proxy host).
**Deliverable**: Each unikernel-capable node has a `mcp-br0` bridge
with address `10.99.0.1/24`.
### 2.2 TAP Device Management
Each VM gets a TAP device attached to the bridge. The agent creates
and destroys TAP devices as part of the VM lifecycle:
```go
func (q *QEMURuntime) createTAP(name string) (string, error) {
tapName := fmt.Sprintf("tap-%s", name) // max 15 chars for IFNAMSIZ
// ip tuntap add dev <tap> mode tap user mcp
// ip link set <tap> master mcp-br0
// ip link set <tap> up
return tapName, nil
}
func (q *QEMURuntime) destroyTAP(name string) error {
tapName := fmt.Sprintf("tap-%s", name)
// ip link del <tap>
return nil
}
```
TAP creation requires `CAP_NET_ADMIN` or `ip tuntap` permissions for
the `mcp` user. On NixOS, grant this via a udev rule or by running
the agent with ambient capabilities:
```nix
systemd.services.mcp-agent.serviceConfig.AmbientCapabilities = [
"CAP_NET_ADMIN"
];
```
**QEMU invocation changes** (bridge networking replaces user-mode):
```
qemu-system-x86_64 \
... \
-device virtio-net-pci,netdev=net0,mac=52:54:00:xx:xx:xx \
-netdev tap,id=net0,ifname=tap-<name>,script=no,downscript=no
```
Each VM gets a deterministic MAC address derived from the service
name (e.g., SHA-256 of service name, take 5 bytes, prepend `52:54:00`).
### 2.3 VM IP Assignment
VMs need static IPs on the bridge. No DHCP server -- the agent
assigns IPs and passes them to Nanos via the ops config.
```go
type IPAllocator struct {
mu sync.Mutex
subnet net.IPNet // 10.99.0.0/24
gateway net.IP // 10.99.0.1
assigned map[string]net.IP // service name → IP
next byte // next octet to try (2-254)
}
```
The ops config passes networking to Nanos:
```json
{
"RunConfig": {
"IPAddress": "10.99.0.5",
"NetMask": "255.255.255.0",
"Gateway": "10.99.0.1"
}
}
```
Assigned IPs are persisted in the agent's registry:
```sql
ALTER TABLE components ADD COLUMN vm_ip TEXT;
```
**Deliverable**: Each VM gets a static IP on the bridge. The agent
tracks assignments in its registry.
### 2.4 mc-proxy Route Update
With bridge networking, mc-proxy routes change from
`127.0.0.1:<host_port>` to `10.99.0.<n>:<guest_port>`:
- L7 routes: mc-proxy terminates TLS, forwards to
`10.99.0.<n>:<port>` (plaintext on the bridge).
- L4 routes: mc-proxy passes through to `10.99.0.<n>:<port>` (TLS
end-to-end).
The `ProxyRouter.RegisterRoutes()` method needs to use the VM's
bridge IP instead of `127.0.0.1` for unikernel components. Port
allocation changes: unikernel VMs expose their actual service port
on the bridge (no random host port needed), so `host_port` equals
the route's declared port.
### 2.5 Firewall Rules
The bridge must be locked down so VMs can only reach mc-proxy:
```bash
# Allow established connections back to VMs
iptables -A FORWARD -i mcp-br0 -o mcp-br0 -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow VMs to reach the host (mc-proxy) on the bridge IP
iptables -A INPUT -i mcp-br0 -d 10.99.0.1 -j ACCEPT
# Block VM-to-VM traffic on the bridge
ebtables -A FORWARD -i tap-+ -o tap-+ -j DROP
# Block VMs from reaching anything outside the bridge
iptables -A FORWARD -i mcp-br0 ! -o mcp-br0 -j DROP
```
These rules enforce mandatory mediation: VMs can reach the host
(where mc-proxy listens) but nothing else. No Tailnet, no internet,
no other VMs. All inter-service communication goes through mc-proxy.
On NixOS, these rules go in `networking.firewall` or
`networking.nftables`. On Debian, `/etc/iptables/rules.v4`.
**Deliverable**: VMs are network-isolated. They can only reach
mc-proxy on the host. VM-to-VM and VM-to-Tailnet traffic is blocked.
### 2.6 Phase 2 Validation
1. Deploy the test unikernel from Phase 1 with bridge networking.
2. Verify mc-proxy routes to it via the bridge IP.
3. From inside the VM (via the service's own gRPC or HTTP endpoint),
attempt to reach a Tailnet IP directly -- must fail.
4. Attempt to reach another VM on the bridge -- must fail.
5. Verify the service can reach its dependencies (MCIAS, Metacrypt)
only via mc-proxy on the host.
6. Verify container services are completely unaffected by the bridge.
**Phase 2 complete when**: Unikernel VMs are fully network-isolated
and can only communicate through mc-proxy. The agent enforces this
structurally, not cooperatively.
---
## Phase 3: Snapshots and Observability
**Goal**: Ensure unikernel services participate in the snapshot and
monitoring systems. Adapt debugging tools for the no-shell environment.
### 3.1 Snapshot Adaptation
The default snapshot method (tar `*.toml`, `*.db`, `*.pem` from the
host-side `/srv/<service>/`) works unchanged for unikernels because
the agent tars the host directory, not the VM filesystem. The 9p
passthrough means writes from the VM appear on the host immediately.
The `grpc` snapshot method also works unchanged -- the agent calls the
service's `SnapshotService.Snapshot` RPC over mc-proxy, which reaches
the VM the same way any other gRPC call does.
**What doesn't work**: `cli` and `exec:` methods, because there is
no shell inside the VM. Validation (from Phase 1.4) already rejects
these combinations, but the snapshot scheduler should also log a
warning if it encounters a unikernel service with an incompatible
snapshot method.
**Deliverable**: Snapshots work for unikernel services using the
default or `grpc` methods.
### 3.2 Serial Console Log Collection
QEMU writes serial console output to
`/srv/mcp/vm-state/<name>/console.log`. The `Logs()` method on
`QEMURuntime` reads this file. But the agent's `Logs` gRPC RPC
currently streams from podman/journalctl.
Extend the `Logs` RPC to detect the component's runtime and read from
the serial console log instead:
```go
func (a *Agent) Logs(req *pb.LogsRequest, stream pb.McpAgent_LogsServer) error {
comp := a.registryComponent(req)
if comp.Runtime == "unikernel" {
return a.streamSerialLog(comp, req, stream)
}
return a.streamContainerLog(comp, req, stream)
}
```
For Nanos, configure the Go binary's logging to write to stdout/stderr
(which Nanos routes to the serial console). This is the default Go
behavior, so no changes needed in the services themselves.
**Deliverable**: `mcp logs <service>` works for unikernel services,
streaming the serial console output.
### 3.3 Health Check Adaptation
The v2 health check types (tcp, grpc, http) all work over the network
and don't require shell access. No changes needed -- the agent's
monitoring loop connects to the VM's port via mc-proxy or the bridge
IP the same way it does for containers.
### 3.4 Drift Detection
The agent's `LiveCheck()` currently calls `Runtime.List()` and
reconciles with the registry. The QEMU `List()` implementation
enumerates running VMs by checking PIDs in
`/srv/mcp/vm-state/*/qemu.pid`. This needs to be reliable:
- On agent restart, rebuild the `vms` map from the state directory.
- QEMU processes started with `--daemonize` survive agent restarts.
- The QMP socket reconnects on agent restart.
**Deliverable**: Drift detection works for unikernel VMs. Agent
restart does not lose track of running VMs.
### 3.5 Phase 3 Validation
1. Deploy a unikernel service with `[snapshot] method = "grpc"`.
2. `mcp snapshot <service>` -- verify snapshot succeeds.
3. Verify scheduled snapshots include the unikernel service.
4. `mcp logs <service>` -- verify serial console output streams.
5. Kill the QEMU process manually. Verify drift detection catches it
and reports the service as unhealthy.
6. Restart the agent. Verify it rediscovers running VMs.
---
## Phase 4: Image Attestation
**Goal**: The agent verifies that the image it boots matches what the
operator deployed. The master records expected image hashes.
### 4.1 Image Hashing
After building the `.img` file (Phase 1.2), the agent computes its
SHA-256 hash and stores it in the registry:
```sql
ALTER TABLE components ADD COLUMN image_hash TEXT;
```
Before every VM boot, the agent re-hashes the `.img` file and
compares against the stored value. If they don't match, the deploy
fails with an attestation error. This detects:
- Accidental image corruption.
- Tampering with the image file on disk.
- Stale images from a previous deploy.
### 4.2 Master-Side Hash Verification
The agent reports the image hash to the master in the deploy response
and in heartbeats. The master stores expected hashes in its placements
table:
```sql
ALTER TABLE placements ADD COLUMN image_hash TEXT;
```
On reconciliation, the master compares the agent-reported hash against
its stored value. Mismatches are flagged in `mcp status` output.
### 4.3 Build Reproducibility
For attestation to be meaningful, image builds must be reproducible:
the same ELF binary + the same ops config must produce the same `.img`
hash. Nanos/ops builds are deterministic if the config is fixed and
the binary is identical. Document and test this property.
If builds are not reproducible (timestamps, random padding), hash the
ELF binary instead of the `.img` and accept that the image-level hash
is a weaker check.
### 4.4 Phase 4 Validation
1. Deploy a unikernel service. Note the image hash in `mcp status`.
2. Manually modify the `.img` file on disk.
3. Attempt to restart the service -- must fail with attestation error.
4. Redeploy (rebuilds the image) -- must succeed with a new hash.
5. Verify master reconciliation flags hash mismatches.
---
## Phase 5: Service Migration
**Goal**: Convert real platform services from containers to
unikernels, starting with the lowest-risk services and working toward
core infrastructure.
### 5.1 Migration Order
Services are migrated in order of increasing criticality and
decreasing tolerance for disruption:
**Wave 1 -- Stateless/low-risk worker services:**
| Service | Why first | Risk |
|---|---|---|
| mcdoc | Stateless doc renderer. No database. Public-facing but read-only. Failure means docs are down, not data loss. | Very low |
| mcat | MCIAS policy tester. Internal only. No persistent state. | Very low |
**Wave 2 -- Stateful worker services:**
| Service | Why second | Risk |
|---|---|---|
| mcq | Review queue. SQLite database. Has gRPC snapshot support. Good test of 9p + SQLite under unikernel. | Low-medium |
**Wave 3 -- Core infrastructure (only after Waves 1-2 are stable):**
| Service | Considerations | Risk |
|---|---|---|
| mcns | DNS server. Failure affects all name resolution. Must validate that Nanos's network stack handles DNS UDP correctly. | Medium |
| metacrypt | Seal/unseal lifecycle. Sensitive key material in memory. The reduced TCB is most valuable here. | Medium-high |
| mcr | Container registry. Must continue serving OCI images for container-based services that haven't migrated. | Medium |
| mcias | Root dependency. Every other service authenticates through it. Last to migrate. Must be thoroughly validated. | High |
**Not migrated:**
| Service | Reason |
|---|---|
| mc-proxy | Node infrastructure, not a deployed service. Runs on the host. |
| mcp-agent | Node infrastructure. Must have host access. Unikernel isolation is the opposite of what it needs. |
| mcp-master | Same as agent -- needs full host/network access. |
### 5.2 Per-Service Migration Procedure
For each service:
1. **Validate the binary under Nanos locally.** Before touching the
control plane, run `ops run <binary> -c config.json` on a dev
machine. Verify:
- The service starts and passes health checks.
- SQLite opens in WAL mode (if applicable).
- TLS connections work (Nanos's TLS stack handles the Metacrypt CA
cert).
- 9p-mounted files are readable and writable.
2. **Deploy as unikernel on a worker node alongside the container
version.** Use a different service name (e.g., `mcq-uk`) to run
both versions simultaneously. Route test traffic to the unikernel
version via a temporary mc-proxy route.
3. **Validate under real traffic.**
- Health checks pass consistently.
- gRPC and HTTP endpoints respond correctly.
- Snapshots succeed.
- Logs are readable via `mcp logs`.
- SQLite performance is acceptable under 9p (benchmark IOPS).
4. **Cut over.** Update the real service definition to `runtime =
"unikernel"` and redeploy. The master handles the transition:
stop old container, start new unikernel, update routes and DNS.
5. **Soak.** Run for at least one full snapshot cycle (24h) before
declaring stable. Monitor for:
- Memory growth (unikernels have fixed memory, no swap).
- 9p filesystem performance under sustained writes.
- Clock drift (Nanos uses KVM clock, should be fine).
6. **Remove the container fallback.** Once stable, remove the
parallel container deployment.
### 5.3 Rollback
If a unikernel service fails in production:
1. Change `runtime` back to `"container"` in the service definition.
2. `mcp deploy <service>` -- the agent deploys via podman using the
same OCI image (still in MCR).
3. Routes and DNS update automatically.
Both runtimes use the same `/srv/<service>/` data directory, so no
data migration is needed for rollback. The 9p mount is just a view
of the same host directory that containers bind-mount.
### 5.4 Phase 5 Validation
Per wave:
- All services in the wave are running as unikernels.
- Snapshots complete successfully for all migrated services.
- `mcp status` shows all services healthy.
- Edge routing works for public services (mcdoc, mcq).
- No performance regression in SQLite operations.
- Successful `mcp migrate` of a unikernel service between nodes.
---
## Phase 6: Hardening and Long-Term
**Goal**: Operational maturity. The platform is running a mixed fleet
of containers and unikernels reliably.
### 6.1 Agent Upgrade for Unikernel Nodes
`mcp agent upgrade` currently cross-compiles and SCPs the agent
binary. No changes needed -- the agent is host software, not a
unikernel. Running VMs survive agent restarts because QEMU processes
are independent.
### 6.2 Boot Sequence for Unikernel Core Services
If core services (Wave 3) are migrated to unikernels, the agent's
boot sequence config needs to handle QEMU instead of podman for those
stages. The boot sequence already uses service definitions; adding
`runtime = "unikernel"` to a boot-stage service is sufficient.
**Consideration**: QEMU VMs take slightly longer to boot than
containers (BIOS/kernel init). Adjust stage timeouts if needed.
### 6.3 MCR Unikernel Image Storage (Phase 1.2b)
Once the pipeline is stable, implement pre-built unikernel images in
MCR. This eliminates the extract-and-build step on the agent and
ensures image reproducibility.
Add `mcp build` subcommand:
```
mcp build mcq --unikernel # build .img, push to MCR as OCI artifact
mcp build mcq --container # existing behavior
mcp build mcq --all # both
```
### 6.4 Monitoring Dashboard
Add unikernel-specific metrics to `mcp status`:
- VM memory usage (from QMP `query-memory`)
- VM CPU usage (from QMP `query-cpus`)
- 9p I/O statistics
- Image hash and attestation status
- Serial console tail (last N lines)
### 6.5 Future: Capability Tokens
Independent of unikernels but synergistic. With mandatory mediation
(Phase 2), the agent can enforce capability tokens at the network
boundary. This is an MCIAS redesign, not an MCP change:
- MCIAS issues operation-scoped tokens ("bearer may read from mcq
review queue") instead of identity tokens ("bearer is kyle").
- mc-proxy (or agent-level proxy) inspects tokens on forwarded
requests and enforces capabilities.
- Services no longer need to implement their own policy engines --
the mediation layer handles it.
This is a significant design effort and should be its own design
document when the time comes.
---
## Dependency Graph
```
Phase 1.1 (NixOS/KVM setup)
├── Phase 1.2 (image building)
│ │
│ └── Phase 1.3 (QEMU runtime)
│ │
│ ├── Phase 1.4 (service def changes)
│ │
│ └── Phase 1.5 (resource tracking)
│ │
│ └── Phase 1.6 (validation) ─── PHASE 1 DONE
└── Phase 2.1 (bridge setup)
├── Phase 2.2 (TAP management)
│ │
│ └── Phase 2.3 (IP assignment)
│ │
│ └── Phase 2.4 (mc-proxy routes)
└── Phase 2.5 (firewall) ─── Phase 2.6 (validation) ─── PHASE 2 DONE
├── Phase 3 (snapshots/observability) ─── PHASE 3 DONE
└── Phase 4 (attestation) ─── PHASE 4 DONE
└── Phase 5 (service migration)
└── Phase 6 (hardening)
```
Phases 1 and 2 can be partially parallelized: bridge setup (2.1) only
depends on the NixOS/KVM setup (1.1), not on the QEMU runtime being
complete. However, Phase 2 validation requires Phase 1 to be done.
## Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Nanos doesn't support a Go stdlib feature a service uses | Service won't start | Validate each binary under Nanos before committing to migration (Phase 5.2 step 1) |
| 9p performance too slow for SQLite WAL mode | Database operations degrade | Benchmark during Wave 2 (mcq). Fallback: use virtio-blk disk image instead of 9p |
| QEMU memory overhead per VM | Node runs out of memory with many services | Resource tracking (Phase 1.5) prevents overcommit. Budget ~50MB overhead per VM beyond declared memory |
| Bridge networking adds latency | Service response times increase | Measure during Phase 2 validation. The bridge is a software switch -- overhead should be microseconds |
| `ops` tool or Nanos has breaking changes | Image builds fail | Pin ops/Nanos versions. Treat as a dependency like Go or podman |
| KVM not available (RPi, nested virt) | Can't run unikernels on some nodes | Runtime field allows per-service opt-in. Container remains the default. Nodes without KVM simply don't get unikernel placements |
## Non-Goals
- **Replacing containers entirely.** Containers remain the default
runtime. Unikernels are opt-in for services where the isolation
properties justify the debugging trade-offs.
- **Multi-process unikernels.** Services that need sidecars (none
currently) stay as containers.
- **Custom Nanos kernel builds.** Use stock Nanos. If a service needs
kernel customization, it stays as a container.
- **Internet access from VMs.** VMs communicate only through mc-proxy.
If a service needs to reach external APIs, it goes through a
host-side proxy (future work, not in scope).
+19 -106
View File
@@ -9,8 +9,8 @@ the platform's internals.
## Platform Overview
Metacircular is a multi-service infrastructure platform. Services are
Go binaries running as containers across a fleet of Linux nodes,
managed by these core components:
Go binaries running as containers on Linux nodes, managed by these core
components:
| Component | Role |
|-----------|------|
@@ -21,27 +21,8 @@ managed by these core components:
| **MCNS** (DNS) | Authoritative DNS for `*.svc.mcp.metacircular.net` |
The operator workflow is: **build image → push to MCR → write service
definition → deploy via MCP**. MCP handles port assignment, TLS cert
provisioning, route registration, DNS registration, and container
lifecycle.
### Fleet Topology
The platform runs across multiple nodes connected via Tailnet:
| Node | Role | OS | Arch | Purpose |
|------|------|----|------|---------|
| **rift** | Compute + core infra | NixOS | amd64 | Runs most services (Metacrypt, MCR, MCNS, etc.) |
| **svc** | Edge | Debian | amd64 | Public-facing mc-proxy, routes traffic over Tailnet to compute nodes |
| **orion** | Compute | NixOS | amd64 | Provisioned, currently offline |
**Node roles:**
- **Compute nodes** (rift, orion, future RPis) run the full container
lifecycle via rootless Podman.
- **Edge nodes** (svc) run mc-proxy for public traffic routing only.
The MCP agent on edge nodes manages mc-proxy routes but does not run
application containers.
definition → deploy via MCP**. MCP handles port assignment, route
registration, and container lifecycle.
---
@@ -53,7 +34,7 @@ The platform runs across multiple nodes connected via Tailnet:
| Container engine | Docker or Podman (for building images) |
| `mcp` CLI | Installed on the operator workstation |
| MCR access | Credentials to push images to `mcr.svc.mcp.metacircular.net:8443` |
| MCP agent | Running on the target node (`rift` for services, `svc` for edge routing) |
| MCP agent | Running on the target node (currently `rift`) |
| MCIAS account | For `mcp` CLI authentication to the agent |
---
@@ -339,20 +320,14 @@ recreates the containers.
### What Happens During Deploy
1. Agent assigns a free host port (1000060000) for each declared route.
2. For L7 routes, agent provisions a TLS certificate from Metacrypt CA
(via `POST /v1/engine/request`). Certs are written to
`/srv/mc-proxy/certs/<service>.pem` and `.key`. Existing valid certs
(more than 30 days from expiry) are reused.
3. Agent starts containers with `$PORT` / `$PORT_<NAME>` environment
2. Agent starts containers with `$PORT` / `$PORT_<NAME>` environment
variables set to the assigned ports.
4. Agent registers routes with mc-proxy via gRPC (hostname →
`<node-address>:<port>`, mode, TLS cert paths).
5. Agent registers DNS entries in MCNS for
`<service>.svc.mcp.metacircular.net`.
6. Agent records the full state in its SQLite registry.
3. Agent registers routes with mc-proxy (hostname → `127.0.0.1:<port>`,
mode, TLS cert paths).
4. Agent records the full state in its SQLite registry.
On stop (`mcp stop <service>`), the agent reverses the process: removes
DNS entries, removes mc-proxy routes, then stops containers.
mc-proxy routes, then stops containers.
---
@@ -410,14 +385,7 @@ tags = []
level = "info"
```
For services with SSO-enabled web UIs, add:
```toml
[sso]
redirect_uri = "https://<service>.svc.mcp.metacircular.net/sso/callback"
```
For services with a separate web UI binary, add:
For services with a web UI, add:
```toml
[web]
@@ -465,72 +433,18 @@ these.
## 6. Authentication (MCIAS Integration)
Every service delegates authentication to MCIAS. No service maintains
its own user database. Services support two login modes: **SSO
redirect** (recommended for web UIs) and **direct credentials**
(fallback / API clients).
its own user database.
### SSO Login (Web UIs)
SSO is the preferred login method for web UIs. The flow is an OAuth
2.0-style authorization code exchange:
1. User visits the service and is redirected to `/login`.
2. Login page shows a "Sign in with MCIAS" button.
3. Click redirects to MCIAS (`/sso/authorize`), which authenticates the
user.
4. MCIAS redirects back to the service's `/sso/callback` with an
authorization code.
5. The service exchanges the code for a JWT via a server-to-server call
to MCIAS `POST /v1/sso/token`.
6. The JWT is stored in a session cookie.
SSO is enabled by adding an `[sso]` section to the service config and
registering the service as an SSO client in MCIAS.
**Service config:**
```toml
[sso]
redirect_uri = "https://<service>.svc.mcp.metacircular.net/sso/callback"
```
**MCIAS config** (add to the `[[sso_clients]]` list):
```toml
[[sso_clients]]
client_id = "<service>"
redirect_uri = "https://<service>.svc.mcp.metacircular.net/sso/callback"
service_name = "<service>"
```
The `redirect_uri` must match exactly between the service config and
the MCIAS client registration.
When `[sso].redirect_uri` is empty or absent, the service falls back to
the direct credentials form.
**Implementation:** Services use `mcdsl/sso` (v1.7.0+) which handles
state management, CSRF-safe cookies, and the code exchange. The web
server registers three routes:
| Route | Purpose |
|-------|---------|
| `GET /login` | Renders landing page with "Sign in with MCIAS" button |
| `GET /sso/redirect` | Sets state cookies, redirects to MCIAS |
| `GET /sso/callback` | Validates state, exchanges code for JWT, sets session |
### Direct Credentials (API / Fallback)
### Auth Flow
1. Client sends credentials to the service's `POST /v1/auth/login`.
2. Service forwards them to MCIAS via `mcdsl/auth.Authenticator.Login()`.
2. Service forwards them to MCIAS via the client library
(`git.wntrmute.dev/mc/mcias/clients/go`).
3. MCIAS validates and returns a bearer token.
4. Subsequent requests include `Authorization: Bearer <token>`.
5. Service validates tokens via `ValidateToken()`, cached for 30s
5. Service validates tokens via MCIAS `ValidateToken()`, cached for 30s
(keyed by SHA-256 of the token).
Web UIs use this mode when SSO is not configured, presenting a
username/password/TOTP form instead of the SSO button.
### Roles
| Role | Access |
@@ -771,10 +685,9 @@ For reference, these services are operational on the platform:
| Service | Version | Node | Purpose |
|---------|---------|------|---------|
| MCIAS | v1.9.0 | (separate) | Identity and access |
| Metacrypt | v1.4.1 | rift | Cryptographic service, PKI/CA |
| MC-Proxy | v1.2.1 | rift, svc | TLS proxy and router (svc handles public edge) |
| Metacrypt | v1.3.1 | rift | Cryptographic service, PKI/CA |
| MC-Proxy | v1.2.1 | rift | TLS proxy and router |
| MCR | v1.2.1 | rift | Container registry |
| MCNS | v1.1.1 | rift | Authoritative DNS |
| MCDoc | v0.1.0 | rift | Documentation server |
| MCQ | v0.4.0 | rift | Document review queue |
| MCP | v0.7.6 | rift, svc | Control plane agent |
| MCP | v0.7.6 | rift | Control plane agent |
-127
View File
@@ -1,127 +0,0 @@
# Phase E: Multi-Node Orchestration
Phase D (automated DNS registration) is complete. Phase E extends MCP from
a single-node agent on rift to a multi-node fleet with a central master
process.
## Goal
Deploy and manage services across multiple nodes from a single control
plane. The operator runs `mcp deploy` and the system places the workload on
the right node, provisions certs, registers DNS, and configures routing --
same as today on rift, but across the fleet.
## Fleet Topology
| Node | OS | Arch | Role |
|------|----|------|------|
| rift | NixOS | amd64 | Master + worker -- runs mcp-master, core infra, and application services |
| orion | NixOS | amd64 | Worker |
| hyperborea | Debian | arm64 | Worker (Raspberry Pi) |
| svc | Debian | amd64 | Edge -- mc-proxy for public traffic, no containers |
Tailnet is the interconnect between all nodes. Public traffic enters via
mc-proxy on svc, which forwards over Tailnet to worker nodes.
## Key Architecture Decisions
These were resolved in the 2026-04-01 design session:
1. **Rift is the master node.** No separate straylight machine. Core infra
stays on rift, which gains mcp-master alongside its existing agent.
2. **Master-mediated coordination.** Agents never talk to each other. All
cross-node operations go through the master. Agents only dial the master
(for registration and heartbeats) and respond to master RPCs.
3. **Agent self-registration.** Agents register with the master on startup
(name, role, address, arch). The master maintains the live node registry.
No static `[[nodes]]` config required except for bootstrap.
4. **Heartbeats with fallback probe.** Agents push heartbeats every 30s
(with resource data). If the master misses 3 heartbeats (90s), it
actively probes the agent. Failed probe marks the node unhealthy.
5. **Tier-based placement.** `tier = "core"` runs on the master node.
`tier = "worker"` (default) is auto-placed on a worker with capacity.
Explicit `node = "orion"` overrides tier for pinned services.
6. **Two separate certs for public services.** Internal cert
(`svc.mcp.metacircular.net`) issued by worker agent. Public cert
(`metacircular.net`) issued by edge agent. Internal names never
appear on edge certs.
7. **`public = true` on routes.** Public routes declare intent with a
boolean flag. The master assigns the route to an edge node (currently
always svc). No explicit `edge` field in service definitions.
## Components
### Master (`mcp-master`)
Long-lived orchestrator on rift. Responsibilities:
- Accept CLI commands and dispatch to the correct agent
- Maintain node registry from agent self-registration
- Place services based on tier, explicit node, and resource availability
- Detect `public = true` routes and coordinate edge setup
- Validate public hostnames against allowed domain list
- Aggregate status from all agents (fleet-wide view)
- Probe agents on missed heartbeats
The master is stateless in the durable sense -- it rebuilds its world view
from agents on startup. If the master goes down, running services continue
unaffected; only new deploys and rescheduling stop.
### Agent upgrades
The fleet is heterogeneous (NixOS + Debian, amd64 + arm64), so NixOS flake
inputs don't work as a universal update mechanism.
**Design:** MCP owns the binary at `/srv/mcp/mcp-agent` on all nodes.
- `mcp agent upgrade [node]` -- CLI cross-compiles for the target's
GOARCH, SCPs the binary, restarts via SSH
- Node config gains `ssh` (user@host) and `arch` (amd64/arm64) fields
- rift's NixOS `ExecStart` changes from nix store path to
`/srv/mcp/mcp-agent`
- All nodes: binary at `/srv/mcp/mcp-agent`, systemd unit
`mcp-agent.service`
### Edge agents
svc runs an agent but does NOT run containers. Its agent manages mc-proxy
routing only: when the master tells it to set up an edge route, it
provisions a TLS cert from Metacrypt and registers the route in its local
mc-proxy via the gRPC admin API.
## Migration Plan
### Phase 1: Agent on svc
Deploy mcp-agent to svc. Verify with `mcp node list`.
### Phase 2: Edge routing RPCs
Implement SetupEdgeRoute/RemoveEdgeRoute/ListEdgeRoutes on the agent.
Test by calling directly from CLI.
### Phase 3: Build mcp-master
Core loop: registration, heartbeats, deploy routing, placement, edge
coordination.
### Phase 4: Agent registration and health
Self-registration, heartbeat loop, master probe fallback, fleet status.
### Phase 5: Cut over
Point CLI at master, add tier fields to service defs, deploy agents to
orion and hyperborea.
## What Phase E Does NOT Include
These remain future work:
- Auto-reconciliation (agent auto-restarting drifted containers)
- Live migration (snapshot streaming between nodes)
- Web UI for fleet management
- Observability / log aggregation
- Object store
- Multiple edge nodes / master HA
+1 -5
View File
@@ -184,7 +184,7 @@ require git.wntrmute.dev/mc/mcdsl v1.2.0
Every repository has a Makefile with these standard targets:
```makefile
.PHONY: build test vet lint proto-lint clean docker push all
.PHONY: build test vet lint proto-lint clean docker all
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
@@ -218,9 +218,6 @@ clean:
docker:
docker build -t <service> -f Dockerfile.api .
push: docker
docker push $(MCR)/<service>:$(VERSION)
all: vet lint test <service>
```
@@ -233,7 +230,6 @@ all: vet lint test <service>
| `test` | Every change | Yes |
| `proto-lint` | Any proto change | Yes |
| `proto` | After editing `.proto` files | No (manual) |
| `push` | After building container image | No (manual) |
| `all` | Pre-push verification | Yes |
The `all` target is the CI pipeline: `vet → lint → test → build`. If any
-627
View File
@@ -1,627 +0,0 @@
# Building MCQ: A Reading Queue in a Session
*A document reading queue goes from design discussion to production
deployment — including the deployment's sharp edges.*
*Written by Claude (Opus 4.6), reflecting on a collaborative session with
Kyle. This log focuses heavily on the deployment side, including what
went wrong, what was done manually versus what should have been MCP-managed,
and why `mcp ps` doesn't know mcq exists.*
---
## The Idea
Kyle was out and about, away from his tailnet, and wanted to review
platform documentation on his phone. The existing tools — mcdoc (which
renders docs from Gitea repos) and the repos themselves — require either
tailnet access or a desktop workflow.
The concept: a **document queue**. Push raw markdown from inside the
infrastructure, read rendered HTML from anywhere via a browser. Like a
self-hosted Pocket, but for internal docs you're actively iterating on.
After a design discussion, we settled on:
- **Name**: mcq (Metacircular Document Queue)
- **Data model**: Documents keyed by slug, upsert semantics (re-push
replaces content, resets read flag)
- **Auth**: MCIAS on everything — any user including guest can read, any
user including system accounts can push
- **Rendering**: Goldmark with GFM + syntax highlighting, rendered on
each page view
- **Architecture**: Single binary, REST API + gRPC + web UI
## Building the Service
### Codebase Exploration
Before writing any code, I explored the existing platform services to
understand the patterns:
- **mcat** (`~/src/metacircular/mcat/`): Reference for the web UI pattern —
chi router, CSRF, session cookies, htmx, embedded templates, cobra CLI,
config loading via `mcdsl/config`.
- **mcns** (`~/src/metacircular/mcns/`): Reference for REST + gRPC pattern —
separate `internal/server/` (REST) and `internal/grpcserver/` (gRPC),
method maps for auth interceptors, SQLite via `mcdsl/db`.
- **mcdoc** (`~/src/metacircular/mcdoc/`): Reference for goldmark rendering
and plain HTTP serving (mcdoc doesn't use mcdsl for config or HTTP — it
has its own, because it serves plain HTTP behind mc-proxy).
- **mcdsl** (`~/src/metacircular/mcdsl/`): The shared library — auth,
config, db, httpserver, grpcserver, csrf, web packages.
### Implementation (on vade, Kyle's workstation)
Created `~/src/mcq/` with the standard platform layout:
```
cmd/mcq/ main.go, server.go (cobra CLI)
internal/
config/ custom config (TLS optional, see below)
db/ SQLite schema, migrations, document CRUD
server/ REST API routes and handlers
grpcserver/ gRPC server, interceptors, service handlers
webserver/ Web UI routes, templates, session management
render/ goldmark markdown-to-HTML renderer
proto/mcq/v1/ Protobuf definitions
gen/mcq/v1/ Generated Go code
web/ Embedded templates + static files
deploy/ systemd, examples
```
Key files:
- **Proto** (`proto/mcq/v1/mcq.proto`): DocumentService (ListDocuments,
GetDocument, PutDocument, DeleteDocument, MarkRead, MarkUnread),
AuthService (Login, Logout), AdminService (Health).
- **DB** (`internal/db/documents.go`): Single `documents` table with slug
as unique key. PutDocument uses `INSERT ... ON CONFLICT(slug) DO UPDATE`.
- **REST** (`internal/server/routes.go`): All routes under `/v1/`
`PUT /v1/documents/{slug}` for upsert, standard CRUD otherwise.
- **Web UI** (`internal/webserver/server.go`): Login page, document list
at `/`, rendered markdown reader at `/d/{slug}`.
- **gRPC** (`internal/grpcserver/`): Mirrors REST exactly. Method map puts
all document operations in `authRequiredMethods`, nothing in
`adminRequiredMethods`.
Proto generation ran on vade:
```bash
cd ~/src/mcq
protoc --go_out=. --go_opt=module=git.wntrmute.dev/mc/mcq \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/mc/mcq \
proto/mcq/v1/*.proto
```
### The .gitignore Bug
First `git add -A` missed `cmd/mcq/`, `proto/mcq/`, and `gen/mcq/`. The
`.gitignore` had:
```
mcq
srv/
```
The pattern `mcq` (without a leading slash) matches any file or directory
named `mcq` at any level — so it was ignoring `cmd/mcq/`, `gen/mcq/`, and
`proto/mcq/`. Fixed to:
```
/mcq
/srv/
```
### The TLS Decision
This was the most consequential design decision for deployment.
The standard platform pattern (mcdsl's `httpserver`) enforces TLS 1.3
minimum. But mc-proxy on svc terminates TLS at the edge and forwards to
backends as plain HTTP (for localhost services) or HTTPS (for remote
backends like rift). Gitea on svc runs plain HTTP on port 3000 behind
mc-proxy. mcdoc on rift runs plain HTTP on port 38080 behind mc-proxy.
mcdsl's `config.Load` validates that `tls_cert` and `tls_key` are present
— they're required fields. So I couldn't use `config.Base` with empty TLS
fields.
**Solution**: Created `internal/config/config.go` — mcq's own config
package, modeled after mcdoc's. Same TOML loading, env var overrides, and
validation, but TLS fields are optional. When empty, the server uses
`http.ListenAndServe()` instead of `httpserver.ListenAndServeTLS()`.
This meant giving up the mcdsl httpserver (with its logging middleware and
TLS enforcement) for the plain HTTP path. The gRPC server was also dropped
from the svc deployment since it requires TLS. The REST API and web UI
are sufficient for the use case.
### Build and Test (on vade)
```bash
cd ~/src/mcq
go mod tidy
go build ./... # clean
go vet ./... # clean
go test ./... # 6 tests pass (all in internal/db)
# Production binary
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=v0.1.0" \
-o mcq ./cmd/mcq
# Result: 21MB static binary
```
---
## Deployment
### Why mcq is NOT in `mcp ps`
**This is the most important thing in this log.**
mcq was deployed as a **manual systemd service on svc**, not as an
MCP-managed container. This means:
- `mcp ps` doesn't know about it
- `mcp stop mcq` won't work
- `mcp deploy mcq` won't work
- There's no service definition in `~/.config/mcp/services/`
- There's no container image in MCR
- The binary was `scp`'d directly to svc and `install`'d to `/usr/local/bin/`
**Why?** Three reasons:
1. **svc has no MCP agent.** The MCP agent (`mcp-agent`) only runs on rift.
svc is a Debian VPS that hosts MCIAS, mc-proxy, MCNS, and Gitea — all
deployed as manual systemd services, not via MCP. Getting mcq into MCP
would require deploying an MCP agent to svc first (Phase E in
PLATFORM_EVOLUTION.md, items #10-#12).
2. **mcq runs as a native binary, not a container.** MCP manages containers
(podman). mcq on svc is a bare binary under systemd, like MCIAS and
mc-proxy on svc. To make it MCP-managed, it would need to be
containerized and pushed to MCR first.
3. **The deployment followed the existing svc pattern.** Every service on
svc was deployed this way: build on vade, scp to svc, install, write
config, write systemd unit, enable. This was a deliberate choice to
match the existing operational model rather than block on MCP agent
deployment.
### What MCP-managed deployment would look like
Once svc has an MCP agent, mcq could be managed like services on rift:
```toml
# ~/.config/mcp/services/mcq.toml
name = "mcq"
node = "svc"
version = "v0.1.0"
[[components]]
name = "api"
[[components.routes]]
port = 8090
mode = "l7"
hostname = "mcq.metacircular.net"
```
This would require:
- MCP agent running on svc
- mcq containerized (Dockerfile) and pushed to MCR
- Agent handles port assignment, mc-proxy route registration, lifecycle
### The Actual Deployment Steps
All commands below were run from vade (Kyle's workstation) via SSH to svc,
unless otherwise noted.
#### 1. Push repo to Gitea (from vade)
```bash
cd ~/src/mcq
git remote add origin git@git.wntrmute.dev:mc/mcq.git
git push -u origin master
```
The mc/mcq repo was created manually in Gitea (the MCP tool's API token
lacked `write:organization` scope for creating repos under the mc org).
#### 2. Copy binary to svc (from vade)
```bash
scp ~/src/mcq/mcq kyle@svc:/tmp/mcq
```
SSH to svc uses Tailscale hostname resolution — `svc` resolves to
`100.106.232.4` via tailscale. No SSH config entry was needed. Had to
accept the host key on first connection:
```bash
ssh -o StrictHostKeyChecking=accept-new kyle@svc
```
#### 3. Create user and install binary (on svc, as root via sudo)
```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin mcq
sudo mkdir -p /srv/mcq
sudo chown mcq:mcq /srv/mcq
sudo chmod 0700 /srv/mcq
sudo install -m 0755 /tmp/mcq /usr/local/bin/mcq
```
Verified: `/usr/local/bin/mcq --version``mcq version v0.1.0`
#### 4. Write config (on svc)
Created `/srv/mcq/mcq.toml`:
```toml
[server]
listen_addr = "127.0.0.1:8090"
[database]
path = "/srv/mcq/mcq.db"
[mcias]
server_url = "https://mcias.metacircular.net:8443"
ca_cert = "/srv/mcq/ca.pem"
service_name = "mcq"
tags = []
[log]
level = "info"
```
**Important detail**: The first attempt used `server_url = "https://127.0.0.1:8443"`
which failed because MCIAS's TLS cert has SANs for `mcias.wntrmute.dev`
and `mcias.metacircular.net` but **not** `127.0.0.1` or `localhost`. Token
validation returned "invalid or expired token" because the mcdsl auth
client couldn't establish a TLS connection to MCIAS.
Fixed by copying the pattern from MCNS on svc:
- `server_url = "https://mcias.metacircular.net:8443"` (uses the hostname
that matches the cert's SAN)
- `ca_cert = "/srv/mcq/ca.pem"` (the WNTRMUTE root CA cert, copied from
`/srv/mcns/certs/ca.pem`)
The hostname `mcias.metacircular.net` resolves to svc's public IP, so
this still connects to localhost MCIAS — it just goes through the public
IP for TLS hostname verification. (On a locked-down firewall this could
be an issue, but svc allows loopback through its public IP.)
#### 5. Create systemd unit (on svc)
Created `/etc/systemd/system/mcq.service`:
```ini
[Unit]
Description=MCQ Document Queue
After=network-online.target mcias.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/mcq server --config /srv/mcq/mcq.toml
WorkingDirectory=/srv/mcq
Restart=on-failure
RestartSec=5
User=mcq
Group=mcq
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/srv/mcq
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now mcq
```
Verified running: PID 3765144, memory 7.8MB, started cleanly.
#### 6. Generate TLS certificate for mc-proxy (on vade)
mc-proxy needs a TLS cert for the `mcq.metacircular.net` hostname (it
terminates TLS at the edge). Generated using the local WNTRMUTE root CA:
```bash
cd /tmp
openssl ecparam -name prime256v1 -genkey -noout -out mcq.key
openssl req -new -key mcq.key -out mcq.csr \
-subj "/CN=mcq.metacircular.net/O=Metacircular Dynamics" \
-addext "subjectAltName=DNS:mcq.metacircular.net"
openssl x509 -req -in mcq.csr \
-CA ~/src/metacircular/ca/ca.pem \
-CAkey ~/src/metacircular/ca/ca.key \
-CAcreateserial -out mcq.pem -days 365 -sha256 \
-extfile <(echo "subjectAltName=DNS:mcq.metacircular.net
keyUsage=digitalSignature
extendedKeyUsage=serverAuth")
```
The CA key and cert are at `~/src/metacircular/ca/` — this is the
WNTRMUTE Issuing Authority root CA. Not Metacrypt (which has its own
intermediate CA for automated issuance). The existing mc-proxy certs
(docs, git, metacrypt) were all signed by this same root CA.
Copied to svc:
```bash
scp /tmp/mcq.pem /tmp/mcq.key kyle@svc:/tmp/
```
Installed on svc:
```bash
sudo cp /tmp/mcq.pem /srv/mc-proxy/certs/mcq.metacircular.net.pem
sudo cp /tmp/mcq.key /srv/mc-proxy/certs/mcq.metacircular.net.key
sudo chown mc-proxy:mc-proxy /srv/mc-proxy/certs/mcq.metacircular.net.*
sudo chmod 0600 /srv/mc-proxy/certs/mcq.metacircular.net.key
```
#### 7. Add mc-proxy route (on svc)
mc-proxy on svc uses SQLite for route persistence. The TOML config only
seeds the database on first run (`store.IsEmpty()` check). After that,
routes are loaded from SQLite. So editing the TOML alone doesn't add a
route — you must also insert into the database.
I did both (TOML for documentation/re-seeding, SQLite for immediate effect):
**TOML** (added via `sed` to `/srv/mc-proxy/mc-proxy.toml`):
```toml
[[listeners.routes]]
hostname = "mcq.metacircular.net"
backend = "127.0.0.1:8090"
mode = "l7"
tls_cert = "/srv/mc-proxy/certs/mcq.metacircular.net.pem"
tls_key = "/srv/mc-proxy/certs/mcq.metacircular.net.key"
backend_tls = false
```
**SQLite** (direct insert):
```bash
sudo sqlite3 /srv/mc-proxy/mc-proxy.db "
INSERT INTO routes (listener_id, hostname, backend, mode, tls_cert, tls_key, backend_tls)
VALUES (1, 'mcq.metacircular.net', '127.0.0.1:8090', 'l7',
'/srv/mc-proxy/certs/mcq.metacircular.net.pem',
'/srv/mc-proxy/certs/mcq.metacircular.net.key', 0);
"
```
The `listener_id = 1` is the `:443` listener (only listener on svc's
mc-proxy).
**Note on `backend_tls = false`**: mcq serves plain HTTP on localhost.
mc-proxy terminates TLS for the client and forwards as plain HTTP to
`127.0.0.1:8090`. This is the same pattern as Gitea (`127.0.0.1:3000`)
and mcdoc (`100.95.252.120:38080`). Only metacrypt uses `backend_tls = true`
because its backend is on rift over Tailscale.
#### 8. Restart mc-proxy (on svc)
```bash
sudo systemctl restart mc-proxy
```
This was messy. mc-proxy's graceful shutdown waits for in-flight
connections to drain, and the 30-second shutdown timeout was exceeded
(lingering connections from internet scanners hitting git.metacircular.net).
The shutdown hung for ~30 seconds before logging "shutdown timeout exceeded,
forcing close". systemd then moved to `deactivating (stop-sigterm)` state.
Had to force it:
```bash
sudo systemctl kill mc-proxy
sleep 2
sudo systemctl start mc-proxy
```
After restart: `routes=5` (was 4 before mcq). Confirmed:
```bash
curl -sk https://mcq.metacircular.net/v1/health
# {"status":"ok"}
```
#### 9. Push documents (from vade)
Used the mcp-agent service account token (from
`~/data/downloads/service-account-76d35a82-77ca-422f-85a3-b9f9360d5164.token`)
to authenticate API calls. This is a long-lived JWT issued by MCIAS with
`admin` role, `exp` in 2027.
```bash
TOKEN=$(cat ~/data/downloads/service-account-*.token)
# Push MCP Architecture
python3 -c "
import json
body = open('mcp/ARCHITECTURE.md').read()
print(json.dumps({'title': 'MCP Architecture', 'body': body}))
" | curl -sk -X PUT https://mcq.metacircular.net/v1/documents/mcp-architecture \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @-
# Push Platform Evolution
python3 -c "
import json
body = open('PLATFORM_EVOLUTION.md').read()
print(json.dumps({'title': 'Platform Evolution', 'body': body}))
" | curl -sk -X PUT https://mcq.metacircular.net/v1/documents/platform-evolution \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @-
# Push Packaging doc
python3 -c "
import json
body = open('docs/packaging-and-deployment.md').read()
print(json.dumps({'title': 'Packaging and Deployment', 'body': body}))
" | curl -sk -X PUT https://mcq.metacircular.net/v1/documents/packaging-and-deployment \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @-
```
Used `python3` for JSON encoding because `jq` isn't installed on vade
(NixOS — would need to add it to the system config or use `nix-shell`).
All three documents pushed successfully. The token identifies as
`mcp-agent` (the service account name), so `pushed_by` shows `mcp-agent`
on each document.
### Subsequent Update: Tufte Theme
Kyle wanted a wider reading area (70%) and a Tufte-inspired theme. Updated
`web/static/style.css`:
- Serif font stack (Georgia, Palatino)
- Cream background (`#fffff8`)
- Italic headings, small-caps labels
- `width: 70%` on `.page-container` (was `max-width: 720px`)
- Minimal chrome — document list uses ruled lines instead of cards,
tables use bottom-borders only
- Mobile fallback: full width below 768px
Rebuilt, deployed same way:
```bash
# On vade
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=v0.1.1" \
-o mcq ./cmd/mcq
scp mcq kyle@svc:/tmp/mcq
# On svc
sudo install -m 0755 /tmp/mcq /usr/local/bin/mcq
sudo systemctl restart mcq
```
---
## State After This Session
### What's running on svc
| Service | Port | Managed by | Notes |
|---------|------|------------|-------|
| MCIAS | :8443/:9443 | systemd | Identity/auth, been here longest |
| mc-proxy | :443 | systemd | L7 TLS termination, 5 routes |
| MCNS | :53/:8444/:9444 | systemd | Authoritative DNS |
| Gitea | :3000 | systemd | Git hosting |
| **mcq** | **:8090** | **systemd** | **NEW: document queue** |
None of these are MCP-managed. svc has no MCP agent.
### mc-proxy routes on svc
| Hostname | Backend | Mode | TLS Backend |
|----------|---------|------|-------------|
| metacrypt.metacircular.net | 100.95.252.120:18080 | L7 | yes (rift) |
| git.metacircular.net | 127.0.0.1:3000 | L7 | no |
| git.wntrmute.dev | 127.0.0.1:3000 | L7 | no |
| docs.metacircular.net | 100.95.252.120:38080 | L7 | no |
| **mcq.metacircular.net** | **127.0.0.1:8090** | **L7** | **no** |
### DNS
`mcq.metacircular.net` is a CNAME to `svc.metacircular.net` (set up by
Kyle at the DNS registrar before this session). mc-proxy's SNI-based
routing handles the rest.
### Documents in queue
| Slug | Title | Pushed By |
|------|-------|-----------|
| mcp-architecture | MCP Architecture | mcp-agent |
| platform-evolution | Platform Evolution | mcp-agent |
| packaging-and-deployment | Packaging and Deployment | mcp-agent |
### Git
Repo: `mc/mcq` on Gitea (`git.wntrmute.dev:mc/mcq.git`)
Commits:
1. `bc16279` — Initial implementation
2. `648e9dc` — Support plain HTTP mode for mc-proxy L7 deployment
3. `a5b90b6` — Switch to Tufte-inspired reading theme
---
## What Would Be Different with MCP
If svc had an MCP agent and mcq were containerized:
1. **No manual SSH**`mcp deploy mcq` from vade would push the service
definition, agent would pull the image from MCR.
2. **No manual port picking** — agent assigns a free port from 10000-60000.
3. **No manual mc-proxy route** — agent calls mc-proxy's gRPC API to
register the route (Phase B, already working on rift).
4. **No manual TLS cert** — agent provisions from Metacrypt CA
(Phase C, already working on rift).
5. **No manual systemd unit** — agent manages the container lifecycle.
6. **`mcp ps` would show mcq** — because the agent tracks it in its
registry.
7. **`mcp stop mcq` / `mcp restart mcq` would work** — standard lifecycle.
The gap is: svc has no agent. That's Phase E work (items #10-#12 in
PLATFORM_EVOLUTION.md). The prerequisites are the agent binary location
convention, SSH-based upgrade tooling, and node provisioning for Debian.
---
## Rough Edges and Lessons
1. **MCIAS cert hostname**: Every new service on svc will hit this. The
MCIAS cert doesn't include localhost as a SAN. Services must use
`server_url = "https://mcias.metacircular.net:8443"` (which routes
through the public IP back to localhost) and include the CA cert.
Could fix by reissuing the MCIAS cert with a localhost SAN.
2. **mc-proxy route persistence**: The TOML-seeds-once-then-SQLite model
means you have to touch two places (TOML for future re-seeds, SQLite
for immediate effect). On rift this is handled by the agent's gRPC
calls. On svc without an agent, it's manual database surgery.
3. **mc-proxy shutdown timeout**: The 30-second timeout isn't enough when
internet scanners maintain persistent connections to git.metacircular.net.
Had to force-kill on restart. Should increase `shutdown_timeout` or
add a SIGKILL escalation in the systemd unit (`TimeoutStopSec=45`,
which sends SIGKILL after 45s).
4. **No jq on vade**: NixOS doesn't have jq in the default system config.
Used python3 as a workaround for JSON encoding. Minor friction.
5. **mcdsl httpserver assumes TLS**: Services behind mc-proxy L7 can't use
`mcdsl/httpserver` because it enforces TLS 1.3. mcdoc solved this with
its own config/server. mcq now does the same. This is a recurring
pattern — might warrant adding a plain HTTP mode to mcdsl httpserver,
or a separate `mcdsl/httpserver/plain` package.
6. **Session cookie Secure flag behind plain HTTP**: The mcdsl `web`
package always sets `Secure: true` on session cookies. This works
behind mc-proxy L7 because the *browser* sees HTTPS (mc-proxy
terminates TLS) — the `Secure` flag is about the browser's view of
the connection, not the backend. If mcq were ever accessed directly
(not through mc-proxy), cookies would silently fail.
-158
View File
@@ -1,158 +0,0 @@
# MCP Goes Multi-Node: Debugging the Edge
*A day of operational firefighting leads to an architecture redesign.
What started as "why can't I see container logs" ended with a v2
architecture document and a plan to introduce mcp-master.*
*Written by Claude (Opus 4.6), reflecting on a collaborative session with
Kyle.*
---
## It Started with Logs
The first problem was simple: `mcp logs mcns` returned "No journal files
were opened due to insufficient permissions." The mcns container uses
podman's journald log driver, so the agent runs `journalctl` to read
logs. But the `mcp` user — running as a system service — didn't have
permission to read the system journal.
The fix was two-part. First, code: add `--user` to `journalctl` for
non-root users, then fall back to `podman logs` when `journalctl` fails
entirely (v0.7.7v0.7.9). Second, operational: add the `mcp` user to the
`systemd-journal` group in the NixOS config so it can actually read the
journal. Neither `journalctl` nor `podman logs` works without the group
membership — `podman logs` silently returns empty because it uses the
journal API internally.
Along the way, we added `mcp node list` showing the agent version
(v0.7.8), which required threading the linker-injected version string
through the Agent struct into the NodeStatus RPC.
## The mcq Deployment Saga
Then Kyle tried to check mcns status and hit a TLS EOF. This led us down
the mcns certificate rabbit hole (self-signed cert instead of one from
Metacrypt), which led to adding a `mcns cert` command for provisioning
certs from Metacrypt's CA API (mcns v1.2.0). But the real story was mcq.
Kyle had deployed an updated mcq earlier, and it broke the public route
at mcq.metacircular.net. What followed was a multi-hour debugging session
that touched every layer of the stack:
**Problem 1: Stale route on rift.** mc-proxy on rift had an old
`mcq.metacircular.net` route pointing to a wrong port. Rift shouldn't
have been routing the public hostname at all — that's svc's job. We
added `mcp route add/remove` commands (v0.8.0) to manage mc-proxy routes
directly, and cleaned up the stale route.
**Problem 2: Dynamic ports.** The route system assigns ephemeral host
ports that change on every deploy. svc's mc-proxy pointed at
`100.95.252.120:48080`, which was a port from a previous deployment.
The new container was listening on a completely different port.
**Problem 3: Rootless podman ports are localhost-only.** Even after
getting the right port, svc couldn't reach it — rootless podman binds
mapped ports to `127.0.0.1`. We added explicit Tailscale IP bindings to
the service definition: `100.95.252.120:48080:8080`.
**Problem 4: $PORT env override conflict.** The mcdsl config loader
overrides `listen_addr` from `$PORT` when routes are present. Adding a
route made the container stop listening on port 8080 and listen on the
route-allocated port instead, breaking the explicit port mapping. We had
to drop the route and manage mc-proxy manually.
**Problem 5: mc-proxy database overrides TOML.** After updating svc's
mc-proxy TOML config, the route still didn't change. mc-proxy persists
routes in SQLite, and the database entry (added via the admin API) took
precedence over the config file. We had to `sqlite3` into the database
and update the route directly. This one took the longest to diagnose —
debug logging finally revealed it was proxying to the old backend.
**Problem 6: Missing cert chain.** The mcq TLS cert on svc was leaf-only
(16 lines). mc-proxy requires full chains (leaf + intermediates). The
cert loaded fine in Go's `tls.LoadX509KeyPair` but mc-proxy's
`GetCertificate` callback failed silently — `client_bytes=7
backend_bytes=0` with no error. We issued a proper cert from Metacrypt
with the full chain.
**Problem 7: Old mc-proxy on svc.** Even with the correct cert, TLS
still failed. svc was running mc-proxy `v1.0.0-dirty` while rift had
`v1.2.1`. We rebuilt and deployed the current version. (This turned out
not to be the actual fix — it was the database issue — but svc needed
the update anyway.)
## The Route Command
Out of the debugging came a useful new tool: `mcp route list/add/remove`
(v0.8.0v0.8.2). It wraps mc-proxy's admin gRPC API through the
mcp-agent, so you can manage routes from the operator workstation:
```
mcp route list -n rift
mcp route add -n rift :443 mcq.svc.mcp.metacircular.net 127.0.0.1:48080 \
--mode l7 --tls-cert /srv/mc-proxy/certs/mcq.pem \
--tls-key /srv/mc-proxy/certs/mcq.key
mcp route remove -n rift :443 mcq.metacircular.net
```
The `--mode` flag wasn't wired through initially (defined on the cobra
command but never passed to the RPC), which we caught when the first L7
route add silently created an L4 route instead.
## Architecture v2
The operational pain made the case for a redesign. Every public route
required hand-editing configs, provisioning certs, debugging database
divergence, and manually coordinating between rift and svc. Kyle laid
out the target architecture:
**mcp-master** on a new node (straylight) becomes the coordination
point. The CLI talks to the master, not agents directly. The master
routes deployments to the correct worker agent (rift), detects public
hostnames, and tells the edge agent (svc) to set up forwarding and
provision certs.
The key insight: the service definition already declares everything
needed. A route with `hostname = "mcq.metacircular.net"` is
unambiguously public (no `.svc.mcp.` prefix). The master can detect this,
resolve the CNAME to find which edge node handles it, and orchestrate the
whole thing — no manual config editing, no database poking, no separate
cert provisioning step.
Core infrastructure (mcns, metacrypt, mcr) moves to straylight. Rift
becomes a pure application worker. svc stays as the public edge, running
only mc-proxy and the routes the master tells it to set up.
The full design is in `ARCHITECTURE_V2.md`, pushed to both git and the
mcq reading queue.
## What Shipped
| Version | Change |
|---------|--------|
| mcp v0.7.7 | Fix journald log permissions for rootless podman |
| mcp v0.7.8 | Add agent version to `mcp node list` |
| mcp v0.7.9 | Fall back to `podman logs` when journalctl inaccessible |
| mcp v0.8.0 | Add `mcp route list/add/remove` with `-n/--node` |
| mcp v0.8.1 | Merge explicit ports with route-allocated ports during deploy |
| mcp v0.8.2 | Wire --mode, --tls-cert, --tls-key through route add |
| mcns v1.2.0 | Add `mcns cert` command for Metacrypt TLS provisioning |
| mc-proxy on svc | Updated from v1.0.0-dirty to v1.2.1 |
| NixOS | Added `systemd-journal` group to mcp user |
## Lessons
The deployment pitfalls doc grew significantly. The key additions for
the future Debian deployment:
1. `mcp` user needs `systemd-journal` group for container logs.
2. Routes and explicit ports conflict via `$PORT` env override.
3. Rootless podman ports need explicit Tailscale IP bindings.
4. mc-proxy certs must include the full chain.
5. mc-proxy's SQLite database overrides the TOML config.
6. Always check the database first when debugging mc-proxy routing.
Every one of these was a surprise. None was documented before today.
The v2 architecture exists specifically so that nobody has to debug
these by hand again.
-231
View File
@@ -1,231 +0,0 @@
# Incident Report: UID Change Cascading Failure
**Date**: 2026-04-03
**Duration**: ~2 hours (08:4510:45 PDT)
**Severity**: Full platform outage on rift (all containers lost)
**Root cause**: Changing the `mcp` system user UID from 995 to 850
## Timeline
### Background
Orion was being provisioned as a new worker node. Its NixOS config
imports `mcp.nix` which pins the mcp user to UID 995. On orion, UID 995
was already assigned to the `sshd` user, causing a UID collision:
```
uid=995(sshd) gid=988(mcp) groups=988(mcp),62(systemd-journal),992(sshd)
```
Both `sshd` and `mcp` had UID 995 on orion. The `newuidmap` tool
rejected rootless podman operations because the calling process's UID
(995) belonged to `sshd`, not `mcp`, in `/etc/passwd`.
### The UID Change
To resolve the collision, `mcp.nix` was updated to pin UID 850 (in
the 800899 range, empty on all nodes). Both rift and orion were
rebuilt with `nixos-rebuild switch`.
**Problem 1: NixOS doesn't change UIDs for existing users.** The
rebuild created the NixOS config with `uid = 850` but the existing
`mcp` user on both nodes kept UID 995. Manual `usermod -u 850 mcp`
was required on each node.
**Problem 2: Rootless podman caches the UID everywhere.**
- Podman's SQLite database (`db.sql`) stores absolute paths like
`/run/user/995/libpod/tmp` and `/run/user/995/containers`
- The systemd user session (`/run/user/995/`) is tied to the UID
- subuid/subgid mappings reference the user by name but the kernel
checks the actual UID
- Container storage overlay directories have file ownership based on
the old UID namespace mapping (995 → 100000)
After changing the UID, `podman` operations failed with:
```
newuidmap: write to uid_map failed: Operation not permitted
```
### The Reboot
Rift was rebooted to get a clean systemd user session for UID 850.
The reboot succeeded, but **all containers were gone**:
```
$ podman ps -a
(empty)
```
Podman's database was recreated fresh on boot because the old database
referenced paths under `/run/user/995/` which no longer existed. The
images were still in overlay storage but the container definitions
(names, port mappings, volume mounts, restart policies) were lost.
### DNS Collapse
MCNS (the authoritative DNS server for `.svc.mcp.metacircular.net`)
ran as a container on rift. When all containers were lost, DNS
resolution broke:
- `mcq.svc.mcp.metacircular.net` → no answer
- MCNS also served as a recursive resolver for the LAN
- `google.com` → NXDOMAIN on machines using MCNS as their resolver
Tailscale DNS (MagicDNS) was also affected because resolved's global
DNS config pointed to MCNS. Tailscale itself remained functional
(its coordination servers are external), but hostname resolution via
Tailscale DNS names failed.
The operator turned off Tailscale on vade (the workstation) because
Tailscale's MagicDNS was routing ALL DNS queries through the broken
MCNS resolver — external services including Claude Code and Gitea
were unreachable. Disabling Tailscale was the only way to restore
external DNS resolution. However, this also broke connectivity to
rift since the MCP agent binds to the Tailnet IP only
(`100.95.252.120:9444`).
### Recovery
**Step 1**: Turn Tailscale back on (on both rift and vade). Tailscale
connectivity works without MCNS — MagicDNS uses Tailscale's own
servers for `.ts.net` names.
**Step 2**: Start MCNS manually via `podman run`. The image was cached
in overlay storage. MCNS needed explicit port bindings (not `--network
host`) because systemd-resolved holds port 53 on localhost:
```bash
podman run -d --name mcns --restart unless-stopped \
-p 192.168.88.181:53:53/tcp -p 192.168.88.181:53:53/udp \
-p 100.95.252.120:53:53/tcp -p 100.95.252.120:53:53/udp \
-p 127.0.0.1:38443:8443 \
-v /srv/mcns:/srv/mcns \
mcr.svc.mcp.metacircular.net:8443/mcns:v1.2.0 \
server --config /srv/mcns/mcns.toml
```
DNS resolution restored within seconds.
**Step 3**: Start remaining services manually via `podman run`. Images
were all cached. The `mcp deploy` CLI couldn't work because:
- MCR was down (can't pull images)
- The agent's registry was empty (podman DB reset)
- Auto-build failed (`/etc/resolv.conf` permission denied in build
containers)
Each service was started with explicit `podman run` commands matching
the service definitions in `~/.config/mcp/services/*.toml`.
**Step 4**: Fix file ownership for rootless podman. Files in `/srv/*`
were owned by UID 850 (the mcp user on the host). Inside containers,
UID 0 (root) maps to host UID 850 via subuid. But:
- `podman unshare chown -R 0:0 /srv/<service>` translated ownership
to match the container's user namespace
- SQLite's `PRAGMA journal_mode = WAL` requires creating WAL/SHM files
in the database directory
- modernc.org/sqlite calls `fchmod()` on the database file, which is
denied inside rootless podman user namespaces (even for UID 0 in the
namespace)
**Step 5**: Delete and recreate SQLite databases. The `fchmod` denial
was fatal for MCR and Metacrypt. The fix:
```bash
# Stop the container
podman stop metacrypt-api
# Delete the database (WAL and SHM too)
rm -f /srv/metacrypt/metacrypt.db*
# Restart — the service recreates the database
podman start metacrypt-api
```
The `fchmod` error still occurs on the newly created database but is
non-fatal — the service logs a warning and continues.
**Data loss**: MCR and Metacrypt databases were deleted and recreated
empty. MCR lost its manifest/tag metadata (images still exist in
overlay storage but are unregistered). Metacrypt lost its CA state
(encrypted keys, issued certs tracking). Other services (mcq, mcdoc,
etc.) started successfully because their databases survived the
ownership changes.
## Root Causes
1. **UID collision between system users**: NixOS auto-assigns UIDs
downward from 999. Pinning UID 995 for mcp collided with sshd on
orion.
2. **Rootless podman's deep UID dependency**: Changing a user's UID
after rootless podman has been used requires:
- Updating podman's internal database paths
- Recreating the systemd user session
- Fixing subuid/subgid mappings
- Fixing overlay storage ownership
- Fixing service data file ownership
- None of these happen automatically
3. **No boot sequencing**: When rift rebooted with no running
containers, there was no mechanism to start services in dependency
order. The boot sequence feature in the v2 architecture exists
precisely for this, but wasn't implemented yet.
4. **MCNS as a single point of DNS failure**: All machines used MCNS
as their DNS resolver. When MCNS went down, everything broke
including the ability to manage infrastructure.
5. **modernc.org/sqlite `fchmod` in rootless podman**: The SQLite
library calls `fchmod()` on database files, which is denied inside
rootless podman user namespaces. This is a known incompatibility
that was masked by the previous UID setup.
## Lessons Learned
1. **Never change a rootless podman user's UID.** If a UID collision
exists, resolve it on the conflicting node (change sshd, not mcp)
or use a per-host UID override. Changing the UID after podman has
been used is destructive.
2. **DNS must not be a single point of failure.** All machines should
have fallback DNS resolvers that work independently of MCNS. The
NixOS config should list public resolvers (1.1.1.1, 8.8.8.8) as
fallbacks, not just MCNS.
3. **Boot sequencing is critical.** The v2 architecture's boot sequence
(foundation → core → management) is not a nice-to-have. Without it,
manual recovery requires knowing the exact dependency order and the
exact `podman run` commands for each service.
4. **The MCP agent should be able to recover containers from its
registry.** After a podman database reset, the agent's SQLite
registry still knows what should be running. A `mcp agent recover`
command that recreates containers from the registry would eliminate
the manual `podman run` recovery.
5. **Service definitions must include all runtime parameters.** The
manual recovery required knowing port mappings, volume mounts,
network modes, user overrides, and command arguments for each
service. All of this is in the service definition files, but there
was no tool to translate a service definition into a `podman run`
command without the full MCP deploy pipeline.
6. **Tailscale MagicDNS amplifies DNS failures.** When MCNS is down
and MagicDNS routes through it, ALL DNS breaks — not just internal
names. Disabling Tailscale restores external DNS but loses Tailnet
connectivity. The fix is fallback resolvers that bypass MCNS, not
disabling Tailscale.
## Action Items
- [x] Write disaster recovery runbook → `docs/disaster-recovery.md`
- [x] Add fallback DNS resolvers to NixOS config → all nodes now have
1.1.1.1 and 8.8.8.8 as fallbacks after MCNS
- [x] Implement `mcp agent recover` command → MCP v0.10.5. Recreates
containers from the agent registry when podman DB is lost.
- [x] Implement boot sequencing in the agent → MCP v0.10.6.
[[boot.sequence]] config with per-stage health checks.
- [x] Fix modernc.org/sqlite `fchmod` → was our own `os.Chmod` in
`mcdsl/db/db.go`, not sqlite. Made best-effort in mcdsl v1.8.0.
- [x] Add multi-address support to node config → MCP v0.10.4.
Fallback addresses tried in order when primary fails.
- [x] Stabilize mcp UID → pinned at 850 with NEVER CHANGE comment
-99
View File
@@ -1,99 +0,0 @@
[0] kyle@vade:~/ > cat ~/.config/mcp/services/mcq.toml
name = 'mcq'
node = 'rift'
active = true
path = '../mcq'
[build.images]
mcq = 'Dockerfile'
[[components]]
name = 'mcq'
image = 'mcr.svc.mcp.metacircular.net:8443/mcq:v0.3.2'
volumes = ['/srv/mcq:/srv/mcq']
cmd = ['server', '--config', '/srv/mcq/mcq.toml']
[[components.routes]]
port = 8443
mode = 'l7'
[0] kyle@vade:~/ > mcp node list
NAME ADDRESS VERSION
rift 100.95.252.120:9444 v0.7.9
[0] kyle@vade:~/ > mcp ps
SERVICE COMPONENT NODE STATE VERSION UPTIME
exo exod rift running v0.1.0 2d16h
kls kls rift running v0.1.2 23h57m
mc-proxy mc-proxy rift running v1.1.1 17h16m
mcat mcat rift running v1.1.1 1d0h
mcdoc mcdoc rift running v0.1.0 2d1h
mcns mcns rift running v1.1.1 1d2h
mcq mcq rift running v0.3.1 2m19s
mcr api rift running v1.2.1 1d2h
mcr web rift running v1.2.1 1d2h
metacrypt api rift running v1.3.1 22h6m
metacrypt web rift running v1.3.1 22h46m
sgard sgardd rift running v3.2.0 2d16h
[0] kyle@vade:~/ > mcp deploy mcq
image mcr.svc.mcp.metacircular.net:8443/mcq:v0.3.2 not found, building from Dockerfile
pushing mcr.svc.mcp.metacircular.net:8443/mcq:v0.3.2
mcq: ok
[0] kyle@vade:~/ > mcp ps
SERVICE COMPONENT NODE STATE VERSION UPTIME
exo exod rift running v0.1.0 2d16h
kls kls rift running v0.1.2 23h58m
mc-proxy mc-proxy rift running v1.1.1 17h16m
mcat mcat rift running v1.1.1 1d0h
mcdoc mcdoc rift running v0.1.0 2d1h
mcns mcns rift running v1.1.1 1d2h
mcq mcq rift running v0.3.2 12s
mcr api rift running v1.2.1 1d2h
mcr web rift running v1.2.1 1d2h
metacrypt api rift running v1.3.1 22h7m
metacrypt web rift running v1.3.1 22h47m
sgard sgardd rift running v3.2.0 2d16h
[0] kyle@vade:~/ > mcp logs mcq -n 10
time=2026-03-30T01:14:42.033Z level=INFO msg="mcq starting" version=dev addr=:57546
[0] kyle@vade:~/ > mcp dns
ZONE: mcp.metacircular.net
NAME TYPE VALUE TTL
ns A 100.95.252.120 300
ns A 192.168.88.181 300
ns A 71.19.144.164 300
rift A 100.95.252.120 300
rift A 192.168.88.181 300
ZONE: svc.mcp.metacircular.net
NAME TYPE VALUE TTL
kls A 192.168.88.181 300
mcat A 192.168.88.181 300
mcdoc A 100.95.252.120 300
mcdoc A 192.168.88.181 300
mcp-agent A 100.95.252.120 300
mcp-agent A 192.168.88.181 300
mcq A 192.168.88.181 300
mcr A 100.95.252.120 300
mcr A 192.168.88.181 300
metacrypt A 100.95.252.120 300
metacrypt A 192.168.88.181 300
sgard A 100.95.252.120 300
sgard A 192.168.88.181 300
[0] kyle@vade:~/ > mcp route list -n rift
NODE: rift
mc-proxy v1.2.1
uptime: 18h15m38s
connections: 0
:443 routes=5 active=0
l7 mcdoc.svc.mcp.metacircular.net → 127.0.0.1:38080
l7 mcr.svc.mcp.metacircular.net → 127.0.0.1:28080 (re-encrypt)
l7 metacrypt.svc.mcp.metacircular.net → 127.0.0.1:18080 (re-encrypt)
l7 kls.svc.mcp.metacircular.net → 127.0.0.1:13881
l4 mcat.svc.mcp.metacircular.net → 127.0.0.1:41235 (re-encrypt)
:8443 routes=3 active=0
l4 metacrypt.svc.mcp.metacircular.net → 127.0.0.1:18443
l7 mcq.svc.mcp.metacircular.net → 127.0.0.1:57546
l4 mcr.svc.mcp.metacircular.net → 127.0.0.1:28443
:9443 routes=3 active=0
l4 sgard.svc.mcp.metacircular.net → 127.0.0.1:19473
l4 mcr.svc.mcp.metacircular.net → 127.0.0.1:29443
l4 metacrypt.svc.mcp.metacircular.net → 127.0.0.1:19443