diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..db3201d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,423 @@ +# MCNS Architecture + +Metacircular Networking Service — an authoritative DNS server for the +Metacircular platform with a management API for dynamic record updates. + +## System Overview + +MCNS replaces the CoreDNS precursor with a purpose-built Go DNS server. +It serves authoritative DNS for platform zones (e.g., +`svc.mcp.metacircular.net`, `mcp.metacircular.net`) and forwards all +other queries to upstream resolvers. Records are stored in SQLite and +managed via gRPC and REST APIs authenticated through MCIAS. + +### Architecture Layers + +``` +┌─────────────────────────────────────────────────────────┐ +│ Clients │ +│ DNS resolvers (port 53) │ API clients (8443/9443) │ +└──────────┬────────────────┴──────────────┬──────────────┘ + │ │ +┌──────────▼──────────┐ ┌───────────────▼───────────────┐ +│ DNS Server │ │ Management API │ +│ UDP+TCP :53 │ │ REST :8443 │ gRPC :9443 │ +│ miekg/dns │ │ chi router │ grpc-go │ +└──────────┬──────────┘ └───────────────┬───────────────┘ + │ │ + │ ┌─────────────────┐ │ + │ │ Auth Layer │ │ + │ │ MCIAS via │ │ + │ │ mcdsl/auth │ │ + │ └────────┬────────┘ │ + │ │ │ +┌──────────▼──────────────────▼────────────▼──────────────┐ +│ Storage Layer │ +│ SQLite (zones + records) │ +└──────────────────────────┬──────────────────────────────┘ + │ + ┌────────────▼────────────┐ + │ Upstream Forwarder │ + │ 1.1.1.1 / 8.8.8.8 │ + └─────────────────────────┘ +``` + +### Design Principles + +1. **DNS is the fast path.** The DNS handler reads from SQLite and + responds. No locks beyond SQLite's own WAL reader concurrency. DNS + queries never touch the auth layer. +2. **Management is the authenticated path.** All record mutations go + through the REST/gRPC API, authenticated via MCIAS. +3. **Immediate consistency.** Record changes are visible to DNS queries + as soon as the SQLite transaction commits. No restart required. +4. **Forward everything else.** Non-authoritative queries are forwarded + to configured upstream resolvers. Forwarded responses are cached + in-memory with TTL-based expiry. + +## Record Types + +v1 supports three record types: + +| Type | Value Format | Example | +|------|-------------|---------| +| A | IPv4 address | `192.168.88.181` | +| AAAA | IPv6 address | `2001:db8::1` | +| CNAME | Fully-qualified domain name | `rift.mcp.metacircular.net.` | + +CNAME records follow standard DNS rules: a name with a CNAME record +must not have any other record types (enforced at the database level). + +## DNS Server + +### Listening + +The DNS server listens on a configurable address (default `:53`) for +both UDP and TCP. UDP is the primary transport; TCP is required for +responses exceeding 512 bytes and for zone transfer compatibility. + +### Query Handling + +``` +Query received + │ + ├─ Is the qname within an authoritative zone? + │ │ + │ ├─ Yes → Look up records from SQLite + │ │ ├─ Records found → Build authoritative response (AA=1) + │ │ └─ No records → Return NXDOMAIN (AA=1, SOA in authority) + │ │ + │ └─ No → Forward to upstream + │ ├─ Check cache → Hit → Return cached response + │ └─ Miss → Query upstream, cache response, return + │ + └─ Special cases: + ├─ SOA query for zone apex → Return SOA from zone config + └─ NS query for zone apex → Return NS from zone config +``` + +### SOA Serial Management + +Zone SOA serial numbers use the YYYYMMDDNN format. When a record is +created, updated, or deleted, the zone's serial is auto-incremented: + +- If the current serial's date prefix matches today, increment NN. +- If the date prefix is older, reset to today with NN=01. +- Maximum 99 changes per day per zone before the counter overflows to + the next day. + +### Forwarding and Caching + +Non-authoritative queries are forwarded to upstream resolvers configured +in `[dns]`. The forwarder: + +- Tries each upstream in order, with a 2-second timeout per attempt. +- Caches successful responses keyed by (qname, qtype, qclass). +- Cache TTL is the minimum TTL from the response, capped at 300 seconds. +- Cache is in-memory with lazy expiry (checked on read). +- SERVFAIL and refused responses are not cached. + +## Storage + +### Database + +SQLite with WAL mode, opened via `mcdsl/db.Open`. Single database file +at the configured path (default `/srv/mcns/mcns.db`). + +### Schema + +```sql +-- Migration 1: zones and records + +CREATE TABLE zones ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + primary_ns TEXT NOT NULL, + admin_email TEXT NOT NULL, + refresh INTEGER NOT NULL DEFAULT 3600, + retry INTEGER NOT NULL DEFAULT 600, + expire INTEGER NOT NULL DEFAULT 86400, + minimum_ttl INTEGER NOT NULL DEFAULT 300, + serial INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE TABLE records ( + id INTEGER PRIMARY KEY, + zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('A', 'AAAA', 'CNAME')), + value TEXT NOT NULL, + ttl INTEGER NOT NULL DEFAULT 300, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(zone_id, name, type, value) +); + +CREATE INDEX idx_records_zone_name ON records(zone_id, name); +``` + +### Constraints + +- Zone names are stored without trailing dot, lowercased. +- Record names are relative to the zone (e.g., `metacrypt` not + `metacrypt.svc.mcp.metacircular.net`). The special name `@` refers + to the zone apex. +- CNAME exclusivity: inserting a CNAME for a name that already has A/AAAA + records (or vice versa) is rejected. Enforced in application logic + within the transaction. +- A record's value must be a valid IPv4 address. AAAA must be valid IPv6. + CNAME must be a valid domain name ending with a dot. + +## Authentication and Authorization + +Authentication is delegated to MCIAS via `mcdsl/auth`. The DNS server +(port 53) has no authentication — it serves DNS to any client, as is +standard for DNS. + +The management API (REST + gRPC) uses MCIAS bearer tokens: + +| Role | Permissions | +|------|-------------| +| admin | Full access: create/update/delete zones and records | +| user | Read-only: list and get zones and records | +| guest | Read-only: list and get zones and records | + +### gRPC Interceptor Maps + +| Map | Methods | +|-----|---------| +| Public | `Health` | +| AuthRequired | `ListZones`, `GetZone`, `ListRecords`, `GetRecord` | +| AdminRequired | `CreateZone`, `UpdateZone`, `DeleteZone`, `CreateRecord`, `UpdateRecord`, `DeleteRecord` | + +## API Surface + +### REST Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/auth/login` | None | Login via MCIAS | +| POST | `/v1/auth/logout` | Bearer | Logout | +| GET | `/v1/health` | None | Health check | +| GET | `/v1/zones` | Bearer | List all zones | +| POST | `/v1/zones` | Admin | Create a zone | +| GET | `/v1/zones/{zone}` | Bearer | Get zone details | +| PUT | `/v1/zones/{zone}` | Admin | Update zone SOA parameters | +| DELETE | `/v1/zones/{zone}` | Admin | Delete zone and all its records | +| GET | `/v1/zones/{zone}/records` | Bearer | List records in a zone | +| POST | `/v1/zones/{zone}/records` | Admin | Create a record | +| GET | `/v1/zones/{zone}/records/{id}` | Bearer | Get a record | +| PUT | `/v1/zones/{zone}/records/{id}` | Admin | Update a record | +| DELETE | `/v1/zones/{zone}/records/{id}` | Admin | Delete a record | + +### gRPC Services + +```protobuf +service AuthService { + rpc Login(LoginRequest) returns (LoginResponse); + rpc Logout(LogoutRequest) returns (LogoutResponse); +} + +service ZoneService { + rpc ListZones(ListZonesRequest) returns (ListZonesResponse); + rpc CreateZone(CreateZoneRequest) returns (Zone); + rpc GetZone(GetZoneRequest) returns (Zone); + rpc UpdateZone(UpdateZoneRequest) returns (Zone); + rpc DeleteZone(DeleteZoneRequest) returns (DeleteZoneResponse); +} + +service RecordService { + rpc ListRecords(ListRecordsRequest) returns (ListRecordsResponse); + rpc CreateRecord(CreateRecordRequest) returns (Record); + rpc GetRecord(GetRecordRequest) returns (Record); + rpc UpdateRecord(UpdateRecordRequest) returns (Record); + rpc DeleteRecord(DeleteRecordRequest) returns (DeleteRecordResponse); +} + +service AdminService { + rpc Health(HealthRequest) returns (HealthResponse); +} +``` + +### Request/Response Examples + +**Create Zone:** +```json +POST /v1/zones +{ + "name": "svc.mcp.metacircular.net", + "primary_ns": "ns.mcp.metacircular.net.", + "admin_email": "admin.metacircular.net." +} + +Response 201: +{ + "id": 1, + "name": "svc.mcp.metacircular.net", + "primary_ns": "ns.mcp.metacircular.net.", + "admin_email": "admin.metacircular.net.", + "refresh": 3600, + "retry": 600, + "expire": 86400, + "minimum_ttl": 300, + "serial": 2026032601, + "created_at": "2026-03-26T20:00:00Z", + "updated_at": "2026-03-26T20:00:00Z" +} +``` + +**Create Record:** +```json +POST /v1/zones/svc.mcp.metacircular.net/records +{ + "name": "metacrypt", + "type": "A", + "value": "192.168.88.181", + "ttl": 300 +} + +Response 201: +{ + "id": 1, + "zone": "svc.mcp.metacircular.net", + "name": "metacrypt", + "type": "A", + "value": "192.168.88.181", + "ttl": 300, + "created_at": "2026-03-26T20:00:00Z", + "updated_at": "2026-03-26T20:00:00Z" +} +``` + +**Error Response:** +```json +{ + "error": "CNAME record conflicts with existing A record for 'metacrypt'" +} +``` + +## Configuration + +```toml +[server] +listen_addr = ":8443" +grpc_addr = ":9443" +tls_cert = "/srv/mcns/certs/cert.pem" +tls_key = "/srv/mcns/certs/key.pem" + +[database] +path = "/srv/mcns/mcns.db" + +[dns] +listen_addr = ":53" +upstreams = ["1.1.1.1:53", "8.8.8.8:53"] + +[mcias] +server_url = "https://svc.metacircular.net:8443" +ca_cert = "" +service_name = "mcns" +tags = [] + +[log] +level = "info" +``` + +### DNS-Specific Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `dns.listen_addr` | No | `:53` | UDP+TCP listen address | +| `dns.upstreams` | No | `["1.1.1.1:53", "8.8.8.8:53"]` | Upstream forwarders | + +## CLI Commands + +| Command | Purpose | +|---------|---------| +| `mcns server` | Start the DNS + API servers | +| `mcns snapshot` | Create a database backup | +| `mcns status` | Query a running instance's health | + +## Deployment + +### Container + +Single Dockerfile, single binary. The container exposes: + +- Port 53 (UDP+TCP) — DNS +- Port 8443 (TCP) — REST API +- Port 9443 (TCP) — gRPC API + +```dockerfile +FROM golang:1.25-alpine AS builder +# ... build with CGO_ENABLED=0 ... + +FROM alpine:3.21 +RUN addgroup -S mcns && adduser -S mcns -G mcns +COPY --from=builder /build/mcns /usr/local/bin/mcns +USER mcns +EXPOSE 53/udp 53/tcp 8443 9443 +ENTRYPOINT ["mcns", "server"] +``` + +### Docker Compose (rift) + +```yaml +services: + mcns: + image: mcr.svc.mcp.metacircular.net:8443/mcns:latest + container_name: mcns + restart: unless-stopped + command: ["server", "--config", "/srv/mcns/mcns.toml"] + ports: + - "192.168.88.181:53:53/udp" + - "192.168.88.181:53:53/tcp" + - "100.95.252.120:53:53/udp" + - "100.95.252.120:53:53/tcp" + volumes: + - /srv/mcns:/srv/mcns +``` + +The API ports (8443/9443) are fronted by MC-Proxy, not exposed directly. + +### Data Directory + +``` +/srv/mcns/ +├── mcns.toml Configuration +├── mcns.db SQLite database +├── certs/ TLS certificates +└── backups/ Database snapshots +``` + +## Security Model + +### Threat Mitigations + +| Threat | Mitigation | +|--------|------------| +| DNS cache poisoning | Forwarded responses validated by upstream; TXID randomized by miekg/dns | +| DNS amplification | Response rate limiting (future); TCP fallback for large responses | +| Unauthorized record modification | Admin-only writes via MCIAS-authenticated API | +| SQL injection | Parameterized queries throughout | +| Zone enumeration via API | Requires authentication (user or admin role) | + +### Security Invariants + +1. DNS port 53 serves read-only data. No mutations possible via DNS protocol. +2. All record mutations require admin authentication through MCIAS. +3. CNAME exclusivity is enforced transactionally — no name can have both + CNAME and address records. +4. Zone serial numbers are monotonically increasing — rollback is not possible. +5. TLS 1.3 minimum on management API (enforced by mcdsl/httpserver). + +## Future Work + +- **MX, TXT, SRV records** — additional record types as needed. +- **DNSSEC** — zone signing for authoritative responses. +- **Zone transfer (AXFR/IXFR)** — for secondary DNS servers. +- **MCP integration** — MCP agent automatically registers service records. +- **Web UI** — htmx interface for zone and record management. +- **Rate limiting** — DNS query rate limiting to mitigate amplification. +- **Metrics** — Prometheus endpoint for query rates, cache hit ratio, latency. diff --git a/CLAUDE.md b/CLAUDE.md index 8a3504c..04dcd1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,46 +2,70 @@ ## Overview -MCNS precursor — a CoreDNS instance serving internal DNS zones for the -Metacircular platform until the full MCNS service is built. +MCNS (Metacircular Networking Service) is an authoritative DNS server for +the Metacircular platform. It serves DNS zones from SQLite, forwards +non-authoritative queries to upstream resolvers, and exposes a gRPC/REST +management API authenticated through MCIAS. -## Zones - -| Zone | Purpose | -|------|---------| -| `svc.mcp.metacircular.net` | Internal service addresses (e.g. `metacrypt.svc.mcp.metacircular.net`) | -| `mcp.metacircular.net` | Node addresses (e.g. `rift.mcp.metacircular.net`) | - -Everything else forwards to 1.1.1.1 and 8.8.8.8. - -## Files - -- `Corefile` — CoreDNS configuration -- `zones/` — Zone files (manually maintained until MCP manages them) -- `deploy/docker/docker-compose-rift.yml` — Docker compose for rift deployment - -## Operations +## Build Commands ```bash -# Start -docker compose -f deploy/docker/docker-compose-rift.yml up -d - -# Test resolution -dig @192.168.88.181 metacrypt.svc.mcp.metacircular.net -dig @192.168.88.181 rift.mcp.metacircular.net - -# After editing zone files, bump the serial and restart -docker compose -f deploy/docker/docker-compose-rift.yml restart +make all # vet → lint → test → build +make build # go build ./... +make test # go test ./... +make vet # go vet ./... +make lint # golangci-lint run ./... +make proto # regenerate gRPC code from .proto files +make proto-lint # buf lint + buf breaking +make mcns # build mcns binary with version injection +make docker # build container image +make clean # remove binaries +make devserver # build and run against srv/ config ``` -## Adding a service +Run a single test: `go test ./internal/dns/ -run TestHandlerA` -1. Add an A record to `zones/svc.mcp.metacircular.net.zone` -2. Bump the serial number (YYYYMMDDNN format) -3. Restart CoreDNS +## Architecture -## Adding a node +Three listeners on one binary: -1. Add an A record to `zones/mcp.metacircular.net.zone` -2. Bump the serial number -3. Restart CoreDNS +- **DNS server** (`:53`, UDP+TCP) — serves authoritative zones from SQLite, + forwards everything else to upstream resolvers. No authentication. +- **REST API** (`:8443`) — management API for zones and records. MCIAS auth. +- **gRPC API** (`:9443`) — same management operations. MCIAS auth. + +Records stored in SQLite. Changes visible to DNS immediately on commit. + +## Project Structure + +``` +cmd/mcns/ CLI entry point (server, snapshot, status) +internal/ + config/ TOML config with DNS-specific sections + db/ SQLite schema, migrations, zone/record queries + dns/ DNS server, handler, forwarder, cache + server/ REST API routes and handlers + grpcserver/ gRPC server, interceptors, service handlers +proto/mcns/v1/ Protobuf definitions +gen/mcns/v1/ Generated Go code (do not edit) +deploy/ Docker, systemd, install scripts, examples +``` + +## Ignored Directories + +- `srv/` — local dev runtime data +- `gen/` — regenerated from proto, not hand-edited + +## Critical Rules + +1. **REST/gRPC sync**: Every REST endpoint must have a corresponding gRPC + RPC, updated in the same change. +2. **gRPC interceptor maps**: New RPCs must be added to the correct + interceptor map (Public, AuthRequired, AdminRequired). Forgetting this + is a security defect. +3. **CNAME exclusivity**: A name cannot have both CNAME and A/AAAA records. + Enforced in the database layer within a transaction. +4. **SOA serial**: Auto-incremented on every record mutation. Never manually + set or decremented. +5. **DNS has no auth**: Port 53 serves records to any client. All mutations + go through the authenticated management API.