ARCHITECTURE.md:
- Note optional ?name=/&type= query filters on GET /v1/zones/{zone}/records
- Document ListRecordsRequest name/type fields as optional filters in gRPC service
- Add gRPC usage examples section with grpcurl commands
CLAUDE.md:
- Add mcdsl shared library section
- Add testing patterns (stdlib only, real SQLite, no mocks)
- Add key invariants: SOA serial YYYYMMDDNN format, CNAME exclusivity at DB layer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
15 KiB
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
- 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.
- Management is the authenticated path. All record mutations go through the REST/gRPC API, authenticated via MCIAS.
- Immediate consistency. Record changes are visible to DNS queries as soon as the SQLite transaction commits. No restart required.
- 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
-- 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.,
metacryptnotmetacrypt.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 (optional ?name=/?type= filters) |
| 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
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 {
// ListRecords returns records in a zone. The name and type fields in
// ListRecordsRequest are optional filters; omit them to return all records.
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:
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:
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:
{
"error": "CNAME record conflicts with existing A record for 'metacrypt'"
}
gRPC Usage Examples
List zones with grpcurl:
grpcurl -cacert ca.pem \
-H "Authorization: Bearer $TOKEN" \
mcns.svc.mcp.metacircular.net:9443 mcns.v1.ZoneService/ListZones
Create a record with grpcurl:
grpcurl -cacert ca.pem \
-H "Authorization: Bearer $TOKEN" \
-d '{"zone":"svc.mcp.metacircular.net","name":"metacrypt","type":"A","value":"192.168.88.181","ttl":300}' \
mcns.svc.mcp.metacircular.net:9443 mcns.v1.RecordService/CreateRecord
List records with name filter:
grpcurl -cacert ca.pem \
-H "Authorization: Bearer $TOKEN" \
-d '{"zone":"svc.mcp.metacircular.net","name":"metacrypt"}' \
mcns.svc.mcp.metacircular.net:9443 mcns.v1.RecordService/ListRecords
Configuration
[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
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)
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
- DNS port 53 serves read-only data. No mutations possible via DNS protocol.
- All record mutations require admin authentication through MCIAS.
- CNAME exclusivity is enforced transactionally — no name can have both CNAME and address records.
- Zone serial numbers are monotonically increasing — rollback is not possible.
- 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.