21 Commits

Author SHA1 Message Date
baa058d4a4 Standardize Makefile docker/push targets for MCR
Add MCR and VERSION variables. Tag images with full MCR registry URL
and version. Add push target that builds then pushes to MCR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:01 -07:00
363c680530 Regenerate proto files for mc/ module path
Raw descriptor bytes in .pb.go files were corrupted by the sed-based
module path rename (string length changed, breaking protobuf binary
encoding). Regenerated with protoc to fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:54:35 -07:00
115802cbe2 Migrate module path from kyle/ to mc/ org
All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:06:00 -07:00
42fff97e17 Merge pull request 'Bump mcdsl for $PORT env var support' (#1) from feature/port-env-adoption into master 2026-03-27 08:16:24 +00:00
0fe2e90d9a Update mcdsl to v1.1.0 (tagged release)
Replace pseudo-version with the tagged v1.1.0 release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:15:32 -07:00
94aa3a9002 Bump mcdsl to f94c4b1 for $PORT env var support
Update mcdsl from v1.0.0 to the port-env-support branch tip, which adds
$PORT environment variable support. Adapt grpcserver.New call to the new
Options parameter (nil for default chain).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:07:19 -07:00
1455ce6e0f Add MCP deployment section to RUNBOOK and service definition
Document MCP-based container management for MCNS on rift, replacing
the docker-compose workflow. Add deploy/mcns-rift.toml as the reference
MCP service definition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:09:40 -07:00
089989ba77 Add migration idempotency test
Verifies that calling Migrate() twice succeeds without error and that
seed data (2 zones, 12 records) is present exactly once.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:19:51 -07:00
f8f3a9868a Apply review fixes: validation, idempotency, SOA dedup, startup cleanup
- Migration v2: INSERT → INSERT OR IGNORE for idempotency
- Config: validate server.tls_cert and server.tls_key are non-empty
- gRPC: add input validation matching REST handlers
- gRPC: add logger to zone/record services, log timestamp parse errors
- REST+gRPC: extract SOA defaults into shared db.ApplySOADefaults()
- DNS: simplify SOA query condition (remove dead code from precedence bug)
- Startup: consolidate shutdown into shutdownAll(), clean up gRPC listener
  on error path, shut down sibling servers when one fails

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:17:15 -07:00
edcf99e8d1 Merge branch 'unit8-grpc-handler-tests' 2026-03-26 21:13:58 -07:00
58f69afd90 Merge branch 'unit10-architecture-claude-docs' 2026-03-26 21:13:53 -07:00
58e756ac06 Merge branch 'unit1-readme-runbook' 2026-03-26 21:13:48 -07:00
82b7d295ef Add gRPC handler tests for zones, records, admin, and interceptors
Full integration tests exercising gRPC services through real server with
mock MCIAS auth. Covers all CRUD operations for zones and records,
health check bypass, auth/admin interceptor enforcement, CNAME
exclusivity conflicts, and method map completeness verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:06:44 -07:00
4ec0c3a916 Add REST API handler tests for zones, records, and middleware
Cover all REST handlers with httptest-based tests using real SQLite:
zones (list, get, create, update, delete), records (list, get, create,
update, delete with validation/conflict cases), requireAdmin middleware
(admin, non-admin, missing context), and utility functions (writeJSON,
writeError, extractBearerToken, tokenInfoFromContext).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:05:54 -07:00
9ac944fb39 Document ListRecords filtering, gRPC examples, and expand CLAUDE.md
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>
2026-03-26 21:05:25 -07:00
96b5a0fa1b Add README.md and RUNBOOK.md per engineering standards
Create the two required documentation files for MCNS:
- README.md: project overview, quick-start (build/configure/run), links
  to ARCHITECTURE.md and RUNBOOK.md
- RUNBOOK.md: operator-focused procedures with numbered steps covering
  health checks, start/stop/restart, backup/restore, log inspection,
  and incident playbooks for database corruption, certificate expiry,
  MCIAS outage, DNS resolution failures, and port conflicts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:04:03 -07:00
7b11f527f2 Add systemd units and install script for MCNS deployment
Ship mcns.service, mcns-backup.service, mcns-backup.timer, and
deploy/scripts/install.sh adapted from MCR's deployment files.
Includes full security hardening block per engineering standards
and AmbientCapabilities=CAP_NET_BIND_SERVICE for DNS port 53.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:02:47 -07:00
efd307f7fd Harden Dockerfile to match MCR production patterns
Add ca-certificates and tzdata packages, fix user creation with proper
home directory and nologin shell, combine RUN commands into a single
layer, add VOLUME/WORKDIR declarations, and reorder USER after volume
setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:02:05 -07:00
5efd51b3d7 Add seed migration with zones and records from CoreDNS zone files
Populates the database on first run with the two existing zones
(svc.mcp.metacircular.net, mcp.metacircular.net) and all their A
records (metacrypt, mcr, sgard, mcp-agent, rift, ns).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:35:16 -07:00
f9635578e0 Implement MCNS v1: custom Go DNS server replacing CoreDNS
Replace the CoreDNS precursor with a purpose-built authoritative DNS
server. Zones and records (A, AAAA, CNAME) are stored in SQLite and
managed via synchronized gRPC + REST APIs authenticated through MCIAS.
Non-authoritative queries are forwarded to upstream resolvers with
in-memory caching.

Key components:
- DNS server (miekg/dns) with authoritative zone handling and forwarding
- gRPC + REST management APIs with MCIAS auth (mcdsl integration)
- SQLite storage with CNAME exclusivity enforcement and auto SOA serials
- 30 tests covering database CRUD, DNS resolution, and caching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:37:14 -07:00
a545fec658 Add ARCHITECTURE.md for custom Go DNS server replacing CoreDNS
Design MCNS as a purpose-built authoritative DNS server with SQLite-backed
zone/record storage and a gRPC+REST management API. Supports A, AAAA, and
CNAME records with upstream forwarding for non-authoritative queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:24:07 -07:00
60 changed files with 8932 additions and 122 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/mcns
srv/
*.db
*.db-wal
*.db-shm
.idea/
.vscode/
.DS_Store

90
.golangci.yaml Normal file
View File

@@ -0,0 +1,90 @@
# golangci-lint v2 configuration for mcns.
# Principle: fail loudly. Security and correctness issues are errors, not warnings.
version: "2"
run:
timeout: 5m
tests: true
linters:
default: none
enable:
# --- Correctness ---
- errcheck
- govet
- ineffassign
- unused
# --- Error handling ---
- errorlint
# --- Security ---
- gosec
- staticcheck
# --- Style / conventions ---
- revive
settings:
errcheck:
check-blank: false
check-type-assertions: true
govet:
enable-all: true
disable:
- shadow
- fieldalignment
gosec:
severity: medium
confidence: medium
excludes:
- G104
errorlint:
errorf: true
asserts: true
comparison: true
revive:
rules:
- name: error-return
severity: error
- name: unexported-return
severity: error
- name: error-strings
severity: warning
- name: if-return
severity: warning
- name: increment-decrement
severity: warning
- name: var-naming
severity: warning
- name: range
severity: warning
- name: time-naming
severity: warning
- name: indent-error-flow
severity: warning
- name: early-return
severity: warning
formatters:
enable:
- gofmt
- goimports
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclusions:
paths:
- vendor
rules:
- path: "_test\\.go"
linters:
- gosec
text: "G101"

450
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,450 @@
# 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 (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
```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 {
// 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:**
```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'"
}
```
### gRPC Usage Examples
**List zones with grpcurl:**
```bash
grpcurl -cacert ca.pem \
-H "Authorization: Bearer $TOKEN" \
mcns.svc.mcp.metacircular.net:9443 mcns.v1.ZoneService/ListZones
```
**Create a record with grpcurl:**
```bash
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:**
```bash
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
```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.

115
CLAUDE.md
View File

@@ -2,46 +2,91 @@
## 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
## Shared Library
MCNS uses `mcdsl` (git.wntrmute.dev/mc/mcdsl) for shared platform packages:
auth, db, config, httpserver, grpcserver. These provide MCIAS authentication,
SQLite database helpers, TOML config loading, and TLS-configured HTTP/gRPC
server scaffolding.
## Testing Patterns
- Use stdlib `testing` only. No third-party test frameworks.
- Tests use real SQLite databases in `t.TempDir()`. No mocks for databases.
## Key Invariants
- **SOA serial format**: YYYYMMDDNN, auto-incremented on every record mutation.
If the date prefix matches today, NN is incremented. Otherwise the serial
resets to today with NN=01.
- **CNAME exclusivity**: Enforced at the DB layer within transactions. A name
cannot have both CNAME and A/AAAA records. Attempts to violate this return
`ErrConflict`.
## 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.

View File

@@ -1,20 +0,0 @@
# Internal zone for Metacircular service discovery.
# Authoritative for svc.mcp.metacircular.net and mcp.metacircular.net.
# Everything else forwards to public resolvers.
svc.mcp.metacircular.net {
file /etc/coredns/zones/svc.mcp.metacircular.net.zone
log
}
mcp.metacircular.net {
file /etc/coredns/zones/mcp.metacircular.net.zone
log
}
. {
forward . 1.1.1.1 8.8.8.8
cache 30
log
errors
}

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM golang:1.25-alpine AS builder
ARG VERSION=dev
RUN apk add --no-cache git
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mcns ./cmd/mcns
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S mcns \
&& adduser -S -G mcns -h /srv/mcns -s /sbin/nologin mcns \
&& mkdir -p /srv/mcns && chown mcns:mcns /srv/mcns
COPY --from=builder /build/mcns /usr/local/bin/mcns
# /srv/mcns is the single volume mount point.
# It must contain:
# mcns.toml — configuration file
# certs/ — TLS certificate and key
# mcns.db — created automatically on first run
VOLUME /srv/mcns
WORKDIR /srv/mcns
EXPOSE 53/udp 53/tcp
EXPOSE 8443
EXPOSE 9443
USER mcns
ENTRYPOINT ["mcns"]
CMD ["server", "--config", "/srv/mcns/mcns.toml"]

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
.PHONY: build test vet lint proto proto-lint clean docker push all devserver
MCR := mcr.svc.mcp.metacircular.net:8443
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION)"
mcns:
CGO_ENABLED=0 go build $(LDFLAGS) -o mcns ./cmd/mcns
build:
go build ./...
test:
go test ./...
vet:
go vet ./...
lint:
golangci-lint run ./...
proto:
protoc --go_out=. --go_opt=module=git.wntrmute.dev/mc/mcns \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/mc/mcns \
proto/mcns/v1/*.proto
proto-lint:
buf lint
buf breaking --against '.git#branch=master,subdir=proto'
clean:
rm -f mcns
docker:
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/mcns:$(VERSION) -f Dockerfile .
push: docker
docker push $(MCR)/mcns:$(VERSION)
devserver: mcns
@mkdir -p srv
@if [ ! -f srv/mcns.toml ]; then cp deploy/examples/mcns.toml srv/mcns.toml; echo "Created srv/mcns.toml from example — edit before running."; fi
./mcns server --config srv/mcns.toml
all: vet lint test mcns

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# MCNS
Metacircular Networking Service -- an authoritative DNS server for the
Metacircular platform. MCNS serves DNS zones backed by SQLite, forwards
non-authoritative queries to upstream resolvers, and exposes a gRPC and
REST management API authenticated through MCIAS. Records are updated
dynamically via the API and visible to DNS immediately on commit.
## Quick Start
Build the binary:
```bash
make mcns
```
Copy and edit the example configuration:
```bash
cp deploy/examples/mcns.toml /srv/mcns/mcns.toml
# Edit TLS paths, database path, MCIAS URL, upstream resolvers
```
Run the server:
```bash
./mcns server --config /srv/mcns/mcns.toml
```
The server starts three listeners:
| Port | Protocol | Purpose |
|------|----------|---------|
| 53 | UDP + TCP | DNS (no auth) |
| 8443 | TCP | REST management API (TLS, MCIAS auth) |
| 9443 | TCP | gRPC management API (TLS, MCIAS auth) |
## Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) -- full technical specification, database schema, API surface, and security model.
- [RUNBOOK.md](RUNBOOK.md) -- operational procedures and incident response for operators.
- [CLAUDE.md](CLAUDE.md) -- context for AI-assisted development.

264
RUNBOOK.md Normal file
View File

@@ -0,0 +1,264 @@
# MCNS Runbook
## Service Overview
MCNS is an authoritative DNS server for the Metacircular platform. It
listens on port 53 (UDP+TCP) for DNS queries, port 8443 for the REST
management API, and port 9443 for the gRPC management API. Zone and
record data is stored in SQLite. All management operations require MCIAS
authentication; DNS queries are unauthenticated.
## Health Checks
### CLI
```bash
mcns status --addr https://localhost:8443
```
With a custom CA certificate:
```bash
mcns status --addr https://localhost:8443 --ca-cert /srv/mcns/certs/ca.pem
```
Expected output: `ok`
### REST
```bash
curl -k https://localhost:8443/v1/health
```
Expected: HTTP 200.
### gRPC
Use the `AdminService.Health` RPC on port 9443. This method is public
(no auth required).
### DNS
```bash
dig @localhost svc.mcp.metacircular.net SOA +short
```
A valid SOA response confirms the DNS listener and database are working.
## Common Operations
### Start the Service
1. Verify config exists: `ls /srv/mcns/mcns.toml`
2. Start the container:
```bash
docker compose -f deploy/docker/docker-compose-rift.yml up -d
```
3. Verify health:
```bash
mcns status --addr https://localhost:8443
```
### Stop the Service
1. Stop the container:
```bash
docker compose -f deploy/docker/docker-compose-rift.yml stop mcns
```
2. MCNS handles SIGTERM gracefully and drains in-flight requests (30s timeout).
### Restart the Service
1. Restart the container:
```bash
docker compose -f deploy/docker/docker-compose-rift.yml restart mcns
```
2. Verify health:
```bash
mcns status --addr https://localhost:8443
```
### Backup (Snapshot)
1. Run the snapshot command:
```bash
mcns snapshot --config /srv/mcns/mcns.toml
```
2. The snapshot is saved to `/srv/mcns/backups/mcns-YYYYMMDD-HHMMSS.db`.
3. Verify the snapshot file exists and has a reasonable size:
```bash
ls -lh /srv/mcns/backups/
```
### Restore from Snapshot
1. Stop the service (see above).
2. Back up the current database:
```bash
cp /srv/mcns/mcns.db /srv/mcns/mcns.db.pre-restore
```
3. Copy the snapshot into place:
```bash
cp /srv/mcns/backups/mcns-YYYYMMDD-HHMMSS.db /srv/mcns/mcns.db
```
4. Start the service (see above).
5. Verify the service is healthy:
```bash
mcns status --addr https://localhost:8443
```
6. Verify zones are accessible by querying DNS:
```bash
dig @localhost svc.mcp.metacircular.net SOA +short
```
### Log Inspection
Container logs:
```bash
docker compose -f deploy/docker/docker-compose-rift.yml logs --tail 100 mcns
```
Follow logs in real time:
```bash
docker compose -f deploy/docker/docker-compose-rift.yml logs -f mcns
```
MCNS logs to stderr as structured text (slog). Log level is configured
via `[log] level` in `mcns.toml` (debug, info, warn, error).
## Incident Procedures
### Database Corruption
Symptoms: server fails to start with SQLite errors, or queries return
unexpected errors.
1. Stop the service.
2. Check for WAL/SHM files alongside the database:
```bash
ls -la /srv/mcns/mcns.db*
```
3. Attempt an integrity check:
```bash
sqlite3 /srv/mcns/mcns.db "PRAGMA integrity_check;"
```
4. If integrity check fails, restore from the most recent snapshot:
```bash
cp /srv/mcns/mcns.db /srv/mcns/mcns.db.corrupt
cp /srv/mcns/backups/mcns-YYYYMMDD-HHMMSS.db /srv/mcns/mcns.db
```
5. Start the service and verify health.
6. Re-create any records added after the snapshot was taken.
### Certificate Expiry
Symptoms: health check fails with TLS errors, API clients get
certificate errors.
1. Check certificate expiry:
```bash
openssl x509 -in /srv/mcns/certs/cert.pem -noout -enddate
```
2. Replace the certificate and key files at the paths in `mcns.toml`.
3. Restart the service to load the new certificate.
4. Verify health:
```bash
mcns status --addr https://localhost:8443
```
### MCIAS Outage
Symptoms: management API returns 502 or authentication errors. DNS
continues to work normally (DNS has no auth dependency).
1. Confirm MCIAS is unreachable:
```bash
curl -k https://svc.metacircular.net:8443/v1/health
```
2. DNS resolution is unaffected -- no immediate action needed for DNS.
3. Management operations (zone/record create/update/delete) will fail
until MCIAS recovers.
4. Escalate to MCIAS (see Escalation below).
### DNS Not Resolving
Symptoms: `dig @<server> <name>` returns SERVFAIL or times out.
1. Verify the service is running:
```bash
docker compose -f deploy/docker/docker-compose-rift.yml ps mcns
```
2. Check that port 53 is listening:
```bash
ss -ulnp | grep ':53'
ss -tlnp | grep ':53'
```
3. Test an authoritative query:
```bash
dig @localhost svc.mcp.metacircular.net SOA
```
4. Test a forwarded query:
```bash
dig @localhost example.com A
```
5. If authoritative queries fail but forwarding works, the database may
be corrupt (see Database Corruption above).
6. If forwarding fails, check upstream connectivity:
```bash
dig @1.1.1.1 example.com A
```
7. Check logs for errors:
```bash
docker compose -f deploy/docker/docker-compose-rift.yml logs --tail 50 mcns
```
### Port 53 Already in Use
Symptoms: MCNS fails to start with "address already in use" on port 53.
1. Identify what is using the port:
```bash
ss -ulnp | grep ':53'
ss -tlnp | grep ':53'
```
2. Common culprit: `systemd-resolved` listening on `127.0.0.53:53`.
- If on a system with systemd-resolved, either disable it or bind
MCNS to a specific IP instead of `0.0.0.0:53`.
3. If another DNS server is running, stop it or change the MCNS
`[dns] listen_addr` in `mcns.toml` to a different address.
4. Restart MCNS and verify DNS is responding.
## Deployment with MCP
MCNS runs on rift as a single container managed by MCP. The service
definition lives at `~/.config/mcp/services/mcns.toml` on the operator's
machine. A reference copy is maintained at `deploy/mcns-rift.toml` in
this repository.
The container image is pulled from MCR. The container mounts `/srv/mcns`
and runs as `--user 0:0`. DNS listens on port 53 (UDP+TCP) on both
192.168.88.181 and 100.95.252.120, with the management API on 8443/9443.
Note: the operator's `~/.config/mcp/services/mcns.toml` may still
reference the old CoreDNS image and needs updating to the new MCNS image.
### Key Operations
1. Deploy or update: `mcp deploy mcns`
2. Restart: `mcp restart mcns`
3. Stop: `mcp stop mcns` (WARNING: stops DNS for all internal zones)
4. Check status: `mcp ps` or `mcp status mcns`
5. View logs: `ssh rift 'doas su - mcp -s /bin/sh -c "podman logs mcns"'`
## Escalation
Escalate when:
- Database corruption cannot be resolved by restoring a snapshot.
- MCIAS is down and management operations are urgently needed.
- DNS resolution failures persist after following the procedures above.
- Any issue not covered by this runbook.
Escalation path: Kyle (platform owner).

9
buf.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE

288
cmd/mcns/main.go Normal file
View File

@@ -0,0 +1,288 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/spf13/cobra"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
"git.wntrmute.dev/mc/mcns/internal/config"
"git.wntrmute.dev/mc/mcns/internal/db"
mcnsdns "git.wntrmute.dev/mc/mcns/internal/dns"
"git.wntrmute.dev/mc/mcns/internal/grpcserver"
"git.wntrmute.dev/mc/mcns/internal/server"
)
var version = "dev"
func main() {
root := &cobra.Command{
Use: "mcns",
Short: "Metacircular Networking Service",
Version: version,
}
root.AddCommand(serverCmd())
root.AddCommand(statusCmd())
root.AddCommand(snapshotCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
func serverCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "server",
Short: "Start the DNS and API servers",
RunE: func(_ *cobra.Command, _ []string) error {
return runServer(configPath)
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "mcns.toml", "path to configuration file")
return cmd
}
func runServer(configPath string) error {
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLogLevel(cfg.Log.Level),
}))
// Open and migrate the database.
database, err := db.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer database.Close()
if err := database.Migrate(); err != nil {
return fmt.Errorf("migrate database: %w", err)
}
// Create auth client for MCIAS integration.
authClient, err := mcdslauth.New(mcdslauth.Config{
ServerURL: cfg.MCIAS.ServerURL,
CACert: cfg.MCIAS.CACert,
ServiceName: cfg.MCIAS.ServiceName,
Tags: cfg.MCIAS.Tags,
}, logger)
if err != nil {
return fmt.Errorf("create auth client: %w", err)
}
// Start DNS server.
dnsServer := mcnsdns.New(database, cfg.DNS.Upstreams, logger)
// Build REST API router.
router := server.NewRouter(server.Deps{
DB: database,
Auth: authClient,
Logger: logger,
})
// TLS configuration.
cert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("load TLS cert: %w", err)
}
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{cert},
}
// HTTP server.
httpServer := &http.Server{
Addr: cfg.Server.ListenAddr,
Handler: router,
TLSConfig: tlsCfg,
ReadTimeout: cfg.Server.ReadTimeout.Duration,
WriteTimeout: cfg.Server.WriteTimeout.Duration,
IdleTimeout: cfg.Server.IdleTimeout.Duration,
}
// Start gRPC server if configured.
var grpcSrv *grpcserver.Server
var grpcLis net.Listener
if cfg.Server.GRPCAddr != "" {
grpcSrv, err = grpcserver.New(cfg.Server.TLSCert, cfg.Server.TLSKey, grpcserver.Deps{
DB: database,
Authenticator: authClient,
}, logger)
if err != nil {
return fmt.Errorf("create gRPC server: %w", err)
}
grpcLis, err = net.Listen("tcp", cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("listen gRPC on %s: %w", cfg.Server.GRPCAddr, err)
}
}
// shutdownAll tears down all servers. Safe to call even if some
// servers were never started. grpcSrv.Serve takes ownership of
// grpcLis, so we only close grpcLis if we never reached Serve.
grpcServeStarted := false
shutdownAll := func() {
dnsServer.Shutdown()
if grpcSrv != nil {
grpcSrv.GracefulStop()
} else if grpcLis != nil && !grpcServeStarted {
_ = grpcLis.Close()
}
shutdownTimeout := 30 * time.Second
if cfg.Server.ShutdownTimeout.Duration > 0 {
shutdownTimeout = cfg.Server.ShutdownTimeout.Duration
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
}
// Graceful shutdown on SIGINT/SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 3)
// Start DNS server.
go func() {
errCh <- dnsServer.ListenAndServe(cfg.DNS.ListenAddr)
}()
// Start gRPC server.
if grpcSrv != nil {
grpcServeStarted = true
go func() {
logger.Info("gRPC server listening", "addr", grpcLis.Addr())
errCh <- grpcSrv.Serve(grpcLis)
}()
}
// Start HTTP server.
go func() {
logger.Info("mcns starting",
"version", version,
"addr", cfg.Server.ListenAddr,
"dns_addr", cfg.DNS.ListenAddr,
)
errCh <- httpServer.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
shutdownAll()
return fmt.Errorf("server error: %w", err)
case <-ctx.Done():
logger.Info("shutting down")
shutdownAll()
logger.Info("mcns stopped")
return nil
}
}
func statusCmd() *cobra.Command {
var addr, caCert string
cmd := &cobra.Command{
Use: "status",
Short: "Check MCNS health",
RunE: func(_ *cobra.Command, _ []string) error {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS13}
if caCert != "" {
pemData, err := os.ReadFile(caCert)
if err != nil {
return fmt.Errorf("read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemData) {
return fmt.Errorf("no valid certificates in %s", caCert)
}
tlsCfg.RootCAs = pool
}
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
Timeout: 5 * time.Second,
}
resp, err := client.Get(addr + "/v1/health")
if err != nil {
return fmt.Errorf("health check: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("health check: status %d", resp.StatusCode)
}
fmt.Println("ok")
return nil
},
}
cmd.Flags().StringVar(&addr, "addr", "https://localhost:8443", "server address")
cmd.Flags().StringVar(&caCert, "ca-cert", "", "CA certificate for TLS verification")
return cmd
}
func snapshotCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "snapshot",
Short: "Database backup via VACUUM INTO",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer database.Close()
backupDir := filepath.Join(filepath.Dir(cfg.Database.Path), "backups")
snapName := fmt.Sprintf("mcns-%s.db", time.Now().Format("20060102-150405"))
snapPath := filepath.Join(backupDir, snapName)
if err := mcdsldb.Snapshot(database.DB, snapPath); err != nil {
return fmt.Errorf("snapshot: %w", err)
}
fmt.Printf("Snapshot saved to %s\n", snapPath)
return nil
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "mcns.toml", "path to configuration file")
return cmd
}
func parseLogLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -1,25 +1,18 @@
# CoreDNS on rift — MCNS precursor.
#
# Serves the svc.mcp.metacircular.net and mcp.metacircular.net zones.
# Forwards everything else to 1.1.1.1 and 8.8.8.8.
# MCNS on rift — authoritative DNS + management API.
#
# Usage:
# docker compose -f deploy/docker/docker-compose-rift.yml up -d
#
# To use as the network's DNS server, point clients or the router at
# rift's IP (192.168.88.181) on port 53.
services:
coredns:
image: coredns/coredns:1.12.1
container_name: mcns-coredns
mcns:
image: mcr.svc.mcp.metacircular.net:8443/mcns:latest
container_name: mcns
restart: unless-stopped
command: -conf /etc/coredns/Corefile
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:
- ../../Corefile:/etc/coredns/Corefile:ro
- ../../zones:/etc/coredns/zones:ro
- /srv/mcns:/srv/mcns

21
deploy/examples/mcns.toml Normal file
View File

@@ -0,0 +1,21 @@
[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"

17
deploy/mcns-rift.toml Normal file
View File

@@ -0,0 +1,17 @@
name = "mcns"
node = "rift"
active = true
[[components]]
name = "dns"
image = "mcr.svc.mcp.metacircular.net:8443/mcns:latest"
user = "0:0"
restart = "unless-stopped"
ports = [
"192.168.88.181:53:53/tcp",
"192.168.88.181:53:53/udp",
"100.95.252.120:53:53/tcp",
"100.95.252.120:53:53/udp",
]
volumes = ["/srv/mcns:/srv/mcns"]
cmd = ["server", "--config", "/srv/mcns/mcns.toml"]

47
deploy/scripts/install.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
SERVICE="mcns"
BINARY="/usr/local/bin/mcns"
DATA_DIR="/srv/${SERVICE}"
UNIT_DIR="/etc/systemd/system"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# Create system user and group (idempotent).
if ! id -u "${SERVICE}" >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin "${SERVICE}"
echo "Created system user ${SERVICE}."
fi
# Install binary.
install -m 0755 "${REPO_DIR}/mcns" "${BINARY}"
echo "Installed binary."
# Create data directory structure.
install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${DATA_DIR}"
install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${DATA_DIR}/backups"
install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${DATA_DIR}/certs"
echo "Created ${DATA_DIR}/."
# Install example config if none exists.
if [ ! -f "${DATA_DIR}/${SERVICE}.toml" ]; then
install -o "${SERVICE}" -g "${SERVICE}" -m 0600 \
"${REPO_DIR}/deploy/examples/mcns.toml" \
"${DATA_DIR}/${SERVICE}.toml"
echo "Installed example config to ${DATA_DIR}/${SERVICE}.toml — edit before starting."
fi
# Install systemd units.
install -m 0644 "${REPO_DIR}/deploy/systemd/${SERVICE}.service" "${UNIT_DIR}/"
install -m 0644 "${REPO_DIR}/deploy/systemd/${SERVICE}-backup.service" "${UNIT_DIR}/"
install -m 0644 "${REPO_DIR}/deploy/systemd/${SERVICE}-backup.timer" "${UNIT_DIR}/"
systemctl daemon-reload
echo "Installed systemd units."
echo ""
echo "Done. Next steps:"
echo " 1. Edit ${DATA_DIR}/${SERVICE}.toml"
echo " 2. Place TLS certs in ${DATA_DIR}/certs/"
echo " 3. systemctl enable --now ${SERVICE}"
echo " 4. systemctl enable --now ${SERVICE}-backup.timer"

View File

@@ -0,0 +1,25 @@
[Unit]
Description=MCNS Database Backup
[Service]
Type=oneshot
User=mcns
Group=mcns
ExecStart=/usr/local/bin/mcns snapshot --config /srv/mcns/mcns.toml
ExecStartPost=/usr/bin/find /srv/mcns/backups -name 'mcns-*.db' -mtime +30 -delete
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
ReadWritePaths=/srv/mcns

View File

@@ -0,0 +1,10 @@
[Unit]
Description=MCNS Daily Database Backup
[Timer]
OnCalendar=*-*-* 02:00:00 UTC
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,34 @@
[Unit]
Description=MCNS Networking Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mcns
Group=mcns
ExecStart=/usr/local/bin/mcns server --config /srv/mcns/mcns.toml
Restart=on-failure
RestartSec=5
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
ReadWritePaths=/srv/mcns
# Allow binding to privileged ports (DNS port 53)
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target

164
gen/mcns/v1/admin.pb.go Normal file
View File

@@ -0,0 +1,164 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: proto/mcns/v1/admin.proto
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type HealthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthRequest) Reset() {
*x = HealthRequest{}
mi := &file_proto_mcns_v1_admin_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthRequest) ProtoMessage() {}
func (x *HealthRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_admin_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead.
func (*HealthRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_admin_proto_rawDescGZIP(), []int{0}
}
type HealthResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthResponse) Reset() {
*x = HealthResponse{}
mi := &file_proto_mcns_v1_admin_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthResponse) ProtoMessage() {}
func (x *HealthResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_admin_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead.
func (*HealthResponse) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_admin_proto_rawDescGZIP(), []int{1}
}
func (x *HealthResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
var File_proto_mcns_v1_admin_proto protoreflect.FileDescriptor
const file_proto_mcns_v1_admin_proto_rawDesc = "" +
"\n" +
"\x19proto/mcns/v1/admin.proto\x12\amcns.v1\"\x0f\n" +
"\rHealthRequest\"(\n" +
"\x0eHealthResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status2I\n" +
"\fAdminService\x129\n" +
"\x06Health\x12\x16.mcns.v1.HealthRequest\x1a\x17.mcns.v1.HealthResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_admin_proto_rawDescOnce sync.Once
file_proto_mcns_v1_admin_proto_rawDescData []byte
)
func file_proto_mcns_v1_admin_proto_rawDescGZIP() []byte {
file_proto_mcns_v1_admin_proto_rawDescOnce.Do(func() {
file_proto_mcns_v1_admin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_admin_proto_rawDesc), len(file_proto_mcns_v1_admin_proto_rawDesc)))
})
return file_proto_mcns_v1_admin_proto_rawDescData
}
var file_proto_mcns_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_proto_mcns_v1_admin_proto_goTypes = []any{
(*HealthRequest)(nil), // 0: mcns.v1.HealthRequest
(*HealthResponse)(nil), // 1: mcns.v1.HealthResponse
}
var file_proto_mcns_v1_admin_proto_depIdxs = []int32{
0, // 0: mcns.v1.AdminService.Health:input_type -> mcns.v1.HealthRequest
1, // 1: mcns.v1.AdminService.Health:output_type -> mcns.v1.HealthResponse
1, // [1:2] is the sub-list for method output_type
0, // [0:1] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_proto_mcns_v1_admin_proto_init() }
func file_proto_mcns_v1_admin_proto_init() {
if File_proto_mcns_v1_admin_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_admin_proto_rawDesc), len(file_proto_mcns_v1_admin_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_mcns_v1_admin_proto_goTypes,
DependencyIndexes: file_proto_mcns_v1_admin_proto_depIdxs,
MessageInfos: file_proto_mcns_v1_admin_proto_msgTypes,
}.Build()
File_proto_mcns_v1_admin_proto = out.File
file_proto_mcns_v1_admin_proto_goTypes = nil
file_proto_mcns_v1_admin_proto_depIdxs = nil
}

View File

@@ -0,0 +1,125 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mcns/v1/admin.proto
package mcnsv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
AdminService_Health_FullMethodName = "/mcns.v1.AdminService/Health"
)
// AdminServiceClient is the client API for AdminService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminService exposes server health and administrative operations.
type AdminServiceClient interface {
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
}
type adminServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminServiceClient(cc grpc.ClientConnInterface) AdminServiceClient {
return &adminServiceClient{cc}
}
func (c *adminServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
err := c.cc.Invoke(ctx, AdminService_Health_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminServiceServer is the server API for AdminService service.
// All implementations must embed UnimplementedAdminServiceServer
// for forward compatibility.
//
// AdminService exposes server health and administrative operations.
type AdminServiceServer interface {
Health(context.Context, *HealthRequest) (*HealthResponse, error)
mustEmbedUnimplementedAdminServiceServer()
}
// UnimplementedAdminServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminServiceServer struct{}
func (UnimplementedAdminServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedAdminServiceServer) mustEmbedUnimplementedAdminServiceServer() {}
func (UnimplementedAdminServiceServer) testEmbeddedByValue() {}
// UnsafeAdminServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminServiceServer will
// result in compilation errors.
type UnsafeAdminServiceServer interface {
mustEmbedUnimplementedAdminServiceServer()
}
func RegisterAdminServiceServer(s grpc.ServiceRegistrar, srv AdminServiceServer) {
// If the following call panics, it indicates UnimplementedAdminServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminService_ServiceDesc, srv)
}
func _AdminService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminServiceServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminService_Health_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminServiceServer).Health(ctx, req.(*HealthRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminService_ServiceDesc is the grpc.ServiceDesc for AdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcns.v1.AdminService",
HandlerType: (*AdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Health",
Handler: _AdminService_Health_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcns/v1/admin.proto",
}

280
gen/mcns/v1/auth.pb.go Normal file
View File

@@ -0,0 +1,280 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: proto/mcns/v1/auth.proto
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type LoginRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"`
// TOTP code for two-factor authentication, if enabled on the account.
TotpCode string `protobuf:"bytes,3,opt,name=totp_code,json=totpCode,proto3" json:"totp_code,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoginRequest) Reset() {
*x = LoginRequest{}
mi := &file_proto_mcns_v1_auth_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoginRequest) ProtoMessage() {}
func (x *LoginRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_auth_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead.
func (*LoginRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_auth_proto_rawDescGZIP(), []int{0}
}
func (x *LoginRequest) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *LoginRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
func (x *LoginRequest) GetTotpCode() string {
if x != nil {
return x.TotpCode
}
return ""
}
type LoginResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoginResponse) Reset() {
*x = LoginResponse{}
mi := &file_proto_mcns_v1_auth_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoginResponse) ProtoMessage() {}
func (x *LoginResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_auth_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead.
func (*LoginResponse) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_auth_proto_rawDescGZIP(), []int{1}
}
func (x *LoginResponse) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
type LogoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
mi := &file_proto_mcns_v1_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogoutRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_auth_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_auth_proto_rawDescGZIP(), []int{2}
}
func (x *LogoutRequest) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
type LogoutResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
mi := &file_proto_mcns_v1_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogoutResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_auth_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_auth_proto_rawDescGZIP(), []int{3}
}
var File_proto_mcns_v1_auth_proto protoreflect.FileDescriptor
const file_proto_mcns_v1_auth_proto_rawDesc = "" +
"\n" +
"\x18proto/mcns/v1/auth.proto\x12\amcns.v1\"c\n" +
"\fLoginRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\x1b\n" +
"\ttotp_code\x18\x03 \x01(\tR\btotpCode\"%\n" +
"\rLoginResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"%\n" +
"\rLogoutRequest\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"\x10\n" +
"\x0eLogoutResponse2\x80\x01\n" +
"\vAuthService\x126\n" +
"\x05Login\x12\x15.mcns.v1.LoginRequest\x1a\x16.mcns.v1.LoginResponse\x129\n" +
"\x06Logout\x12\x16.mcns.v1.LogoutRequest\x1a\x17.mcns.v1.LogoutResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_auth_proto_rawDescOnce sync.Once
file_proto_mcns_v1_auth_proto_rawDescData []byte
)
func file_proto_mcns_v1_auth_proto_rawDescGZIP() []byte {
file_proto_mcns_v1_auth_proto_rawDescOnce.Do(func() {
file_proto_mcns_v1_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_auth_proto_rawDesc), len(file_proto_mcns_v1_auth_proto_rawDesc)))
})
return file_proto_mcns_v1_auth_proto_rawDescData
}
var file_proto_mcns_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_proto_mcns_v1_auth_proto_goTypes = []any{
(*LoginRequest)(nil), // 0: mcns.v1.LoginRequest
(*LoginResponse)(nil), // 1: mcns.v1.LoginResponse
(*LogoutRequest)(nil), // 2: mcns.v1.LogoutRequest
(*LogoutResponse)(nil), // 3: mcns.v1.LogoutResponse
}
var file_proto_mcns_v1_auth_proto_depIdxs = []int32{
0, // 0: mcns.v1.AuthService.Login:input_type -> mcns.v1.LoginRequest
2, // 1: mcns.v1.AuthService.Logout:input_type -> mcns.v1.LogoutRequest
1, // 2: mcns.v1.AuthService.Login:output_type -> mcns.v1.LoginResponse
3, // 3: mcns.v1.AuthService.Logout:output_type -> mcns.v1.LogoutResponse
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_proto_mcns_v1_auth_proto_init() }
func file_proto_mcns_v1_auth_proto_init() {
if File_proto_mcns_v1_auth_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_auth_proto_rawDesc), len(file_proto_mcns_v1_auth_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_mcns_v1_auth_proto_goTypes,
DependencyIndexes: file_proto_mcns_v1_auth_proto_depIdxs,
MessageInfos: file_proto_mcns_v1_auth_proto_msgTypes,
}.Build()
File_proto_mcns_v1_auth_proto = out.File
file_proto_mcns_v1_auth_proto_goTypes = nil
file_proto_mcns_v1_auth_proto_depIdxs = nil
}

163
gen/mcns/v1/auth_grpc.pb.go Normal file
View File

@@ -0,0 +1,163 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mcns/v1/auth.proto
package mcnsv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
AuthService_Login_FullMethodName = "/mcns.v1.AuthService/Login"
AuthService_Logout_FullMethodName = "/mcns.v1.AuthService/Logout"
)
// AuthServiceClient is the client API for AuthService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AuthService handles authentication by delegating to MCIAS.
type AuthServiceClient interface {
Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
}
type authServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAuthServiceClient(cc grpc.ClientConnInterface) AuthServiceClient {
return &authServiceClient{cc}
}
func (c *authServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LoginResponse)
err := c.cc.Invoke(ctx, AuthService_Login_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LogoutResponse)
err := c.cc.Invoke(ctx, AuthService_Logout_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServiceServer is the server API for AuthService service.
// All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility.
//
// AuthService handles authentication by delegating to MCIAS.
type AuthServiceServer interface {
Login(context.Context, *LoginRequest) (*LoginResponse, error)
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
mustEmbedUnimplementedAuthServiceServer()
}
// UnimplementedAuthServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAuthServiceServer struct{}
func (UnimplementedAuthServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Login not implemented")
}
func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Logout not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
// UnsafeAuthServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AuthServiceServer will
// result in compilation errors.
type UnsafeAuthServiceServer interface {
mustEmbedUnimplementedAuthServiceServer()
}
func RegisterAuthServiceServer(s grpc.ServiceRegistrar, srv AuthServiceServer) {
// If the following call panics, it indicates UnimplementedAuthServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AuthService_ServiceDesc, srv)
}
func _AuthService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LoginRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).Login(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_Login_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).Login(ctx, req.(*LoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).Logout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_Logout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).Logout(ctx, req.(*LogoutRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AuthService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcns.v1.AuthService",
HandlerType: (*AuthServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Login",
Handler: _AuthService_Login_Handler,
},
{
MethodName: "Logout",
Handler: _AuthService_Logout_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcns/v1/auth.proto",
}

623
gen/mcns/v1/record.pb.go Normal file
View File

@@ -0,0 +1,623 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: proto/mcns/v1/record.proto
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Record struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
// Zone name this record belongs to (e.g. "example.com.").
Zone string `protobuf:"bytes,2,opt,name=zone,proto3" json:"zone,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
// DNS record type (A, AAAA, CNAME, MX, TXT, etc.).
Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"`
Value string `protobuf:"bytes,5,opt,name=value,proto3" json:"value,omitempty"`
Ttl int32 `protobuf:"varint,6,opt,name=ttl,proto3" json:"ttl,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Record) Reset() {
*x = Record{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Record) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Record) ProtoMessage() {}
func (x *Record) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Record.ProtoReflect.Descriptor instead.
func (*Record) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{0}
}
func (x *Record) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *Record) GetZone() string {
if x != nil {
return x.Zone
}
return ""
}
func (x *Record) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *Record) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *Record) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
func (x *Record) GetTtl() int32 {
if x != nil {
return x.Ttl
}
return 0
}
func (x *Record) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *Record) GetUpdatedAt() *timestamppb.Timestamp {
if x != nil {
return x.UpdatedAt
}
return nil
}
type ListRecordsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Zone string `protobuf:"bytes,1,opt,name=zone,proto3" json:"zone,omitempty"`
// Optional filter by record name.
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
// Optional filter by record type (A, AAAA, CNAME, etc.).
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListRecordsRequest) Reset() {
*x = ListRecordsRequest{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListRecordsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListRecordsRequest) ProtoMessage() {}
func (x *ListRecordsRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListRecordsRequest.ProtoReflect.Descriptor instead.
func (*ListRecordsRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{1}
}
func (x *ListRecordsRequest) GetZone() string {
if x != nil {
return x.Zone
}
return ""
}
func (x *ListRecordsRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ListRecordsRequest) GetType() string {
if x != nil {
return x.Type
}
return ""
}
type ListRecordsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Records []*Record `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListRecordsResponse) Reset() {
*x = ListRecordsResponse{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListRecordsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListRecordsResponse) ProtoMessage() {}
func (x *ListRecordsResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListRecordsResponse.ProtoReflect.Descriptor instead.
func (*ListRecordsResponse) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{2}
}
func (x *ListRecordsResponse) GetRecords() []*Record {
if x != nil {
return x.Records
}
return nil
}
type CreateRecordRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Zone name the record will be created in; must reference an existing zone.
Zone string `protobuf:"bytes,1,opt,name=zone,proto3" json:"zone,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
Value string `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
Ttl int32 `protobuf:"varint,5,opt,name=ttl,proto3" json:"ttl,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateRecordRequest) Reset() {
*x = CreateRecordRequest{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateRecordRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateRecordRequest) ProtoMessage() {}
func (x *CreateRecordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateRecordRequest.ProtoReflect.Descriptor instead.
func (*CreateRecordRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{3}
}
func (x *CreateRecordRequest) GetZone() string {
if x != nil {
return x.Zone
}
return ""
}
func (x *CreateRecordRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *CreateRecordRequest) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *CreateRecordRequest) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
func (x *CreateRecordRequest) GetTtl() int32 {
if x != nil {
return x.Ttl
}
return 0
}
type GetRecordRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetRecordRequest) Reset() {
*x = GetRecordRequest{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetRecordRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRecordRequest) ProtoMessage() {}
func (x *GetRecordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRecordRequest.ProtoReflect.Descriptor instead.
func (*GetRecordRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{4}
}
func (x *GetRecordRequest) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
type UpdateRecordRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
Value string `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
Ttl int32 `protobuf:"varint,5,opt,name=ttl,proto3" json:"ttl,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateRecordRequest) Reset() {
*x = UpdateRecordRequest{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateRecordRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateRecordRequest) ProtoMessage() {}
func (x *UpdateRecordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateRecordRequest.ProtoReflect.Descriptor instead.
func (*UpdateRecordRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{5}
}
func (x *UpdateRecordRequest) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *UpdateRecordRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *UpdateRecordRequest) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *UpdateRecordRequest) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
func (x *UpdateRecordRequest) GetTtl() int32 {
if x != nil {
return x.Ttl
}
return 0
}
type DeleteRecordRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteRecordRequest) Reset() {
*x = DeleteRecordRequest{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteRecordRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteRecordRequest) ProtoMessage() {}
func (x *DeleteRecordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteRecordRequest.ProtoReflect.Descriptor instead.
func (*DeleteRecordRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{6}
}
func (x *DeleteRecordRequest) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
type DeleteRecordResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteRecordResponse) Reset() {
*x = DeleteRecordResponse{}
mi := &file_proto_mcns_v1_record_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteRecordResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteRecordResponse) ProtoMessage() {}
func (x *DeleteRecordResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_record_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteRecordResponse.ProtoReflect.Descriptor instead.
func (*DeleteRecordResponse) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_record_proto_rawDescGZIP(), []int{7}
}
var File_proto_mcns_v1_record_proto protoreflect.FileDescriptor
const file_proto_mcns_v1_record_proto_rawDesc = "" +
"\n" +
"\x1aproto/mcns/v1/record.proto\x12\amcns.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf2\x01\n" +
"\x06Record\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
"\x04zone\x18\x02 \x01(\tR\x04zone\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\x12\x12\n" +
"\x04type\x18\x04 \x01(\tR\x04type\x12\x14\n" +
"\x05value\x18\x05 \x01(\tR\x05value\x12\x10\n" +
"\x03ttl\x18\x06 \x01(\x05R\x03ttl\x129\n" +
"\n" +
"created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
"\n" +
"updated_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"P\n" +
"\x12ListRecordsRequest\x12\x12\n" +
"\x04zone\x18\x01 \x01(\tR\x04zone\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
"\x04type\x18\x03 \x01(\tR\x04type\"@\n" +
"\x13ListRecordsResponse\x12)\n" +
"\arecords\x18\x01 \x03(\v2\x0f.mcns.v1.RecordR\arecords\"y\n" +
"\x13CreateRecordRequest\x12\x12\n" +
"\x04zone\x18\x01 \x01(\tR\x04zone\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
"\x04type\x18\x03 \x01(\tR\x04type\x12\x14\n" +
"\x05value\x18\x04 \x01(\tR\x05value\x12\x10\n" +
"\x03ttl\x18\x05 \x01(\x05R\x03ttl\"\"\n" +
"\x10GetRecordRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\"u\n" +
"\x13UpdateRecordRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
"\x04type\x18\x03 \x01(\tR\x04type\x12\x14\n" +
"\x05value\x18\x04 \x01(\tR\x05value\x12\x10\n" +
"\x03ttl\x18\x05 \x01(\x05R\x03ttl\"%\n" +
"\x13DeleteRecordRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\"\x16\n" +
"\x14DeleteRecordResponse2\xdd\x02\n" +
"\rRecordService\x12H\n" +
"\vListRecords\x12\x1b.mcns.v1.ListRecordsRequest\x1a\x1c.mcns.v1.ListRecordsResponse\x12=\n" +
"\fCreateRecord\x12\x1c.mcns.v1.CreateRecordRequest\x1a\x0f.mcns.v1.Record\x127\n" +
"\tGetRecord\x12\x19.mcns.v1.GetRecordRequest\x1a\x0f.mcns.v1.Record\x12=\n" +
"\fUpdateRecord\x12\x1c.mcns.v1.UpdateRecordRequest\x1a\x0f.mcns.v1.Record\x12K\n" +
"\fDeleteRecord\x12\x1c.mcns.v1.DeleteRecordRequest\x1a\x1d.mcns.v1.DeleteRecordResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_record_proto_rawDescOnce sync.Once
file_proto_mcns_v1_record_proto_rawDescData []byte
)
func file_proto_mcns_v1_record_proto_rawDescGZIP() []byte {
file_proto_mcns_v1_record_proto_rawDescOnce.Do(func() {
file_proto_mcns_v1_record_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_record_proto_rawDesc), len(file_proto_mcns_v1_record_proto_rawDesc)))
})
return file_proto_mcns_v1_record_proto_rawDescData
}
var file_proto_mcns_v1_record_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_proto_mcns_v1_record_proto_goTypes = []any{
(*Record)(nil), // 0: mcns.v1.Record
(*ListRecordsRequest)(nil), // 1: mcns.v1.ListRecordsRequest
(*ListRecordsResponse)(nil), // 2: mcns.v1.ListRecordsResponse
(*CreateRecordRequest)(nil), // 3: mcns.v1.CreateRecordRequest
(*GetRecordRequest)(nil), // 4: mcns.v1.GetRecordRequest
(*UpdateRecordRequest)(nil), // 5: mcns.v1.UpdateRecordRequest
(*DeleteRecordRequest)(nil), // 6: mcns.v1.DeleteRecordRequest
(*DeleteRecordResponse)(nil), // 7: mcns.v1.DeleteRecordResponse
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
}
var file_proto_mcns_v1_record_proto_depIdxs = []int32{
8, // 0: mcns.v1.Record.created_at:type_name -> google.protobuf.Timestamp
8, // 1: mcns.v1.Record.updated_at:type_name -> google.protobuf.Timestamp
0, // 2: mcns.v1.ListRecordsResponse.records:type_name -> mcns.v1.Record
1, // 3: mcns.v1.RecordService.ListRecords:input_type -> mcns.v1.ListRecordsRequest
3, // 4: mcns.v1.RecordService.CreateRecord:input_type -> mcns.v1.CreateRecordRequest
4, // 5: mcns.v1.RecordService.GetRecord:input_type -> mcns.v1.GetRecordRequest
5, // 6: mcns.v1.RecordService.UpdateRecord:input_type -> mcns.v1.UpdateRecordRequest
6, // 7: mcns.v1.RecordService.DeleteRecord:input_type -> mcns.v1.DeleteRecordRequest
2, // 8: mcns.v1.RecordService.ListRecords:output_type -> mcns.v1.ListRecordsResponse
0, // 9: mcns.v1.RecordService.CreateRecord:output_type -> mcns.v1.Record
0, // 10: mcns.v1.RecordService.GetRecord:output_type -> mcns.v1.Record
0, // 11: mcns.v1.RecordService.UpdateRecord:output_type -> mcns.v1.Record
7, // 12: mcns.v1.RecordService.DeleteRecord:output_type -> mcns.v1.DeleteRecordResponse
8, // [8:13] is the sub-list for method output_type
3, // [3:8] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_proto_mcns_v1_record_proto_init() }
func file_proto_mcns_v1_record_proto_init() {
if File_proto_mcns_v1_record_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_record_proto_rawDesc), len(file_proto_mcns_v1_record_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_mcns_v1_record_proto_goTypes,
DependencyIndexes: file_proto_mcns_v1_record_proto_depIdxs,
MessageInfos: file_proto_mcns_v1_record_proto_msgTypes,
}.Build()
File_proto_mcns_v1_record_proto = out.File
file_proto_mcns_v1_record_proto_goTypes = nil
file_proto_mcns_v1_record_proto_depIdxs = nil
}

View File

@@ -0,0 +1,277 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mcns/v1/record.proto
package mcnsv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
RecordService_ListRecords_FullMethodName = "/mcns.v1.RecordService/ListRecords"
RecordService_CreateRecord_FullMethodName = "/mcns.v1.RecordService/CreateRecord"
RecordService_GetRecord_FullMethodName = "/mcns.v1.RecordService/GetRecord"
RecordService_UpdateRecord_FullMethodName = "/mcns.v1.RecordService/UpdateRecord"
RecordService_DeleteRecord_FullMethodName = "/mcns.v1.RecordService/DeleteRecord"
)
// RecordServiceClient is the client API for RecordService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// RecordService manages DNS records within zones.
type RecordServiceClient interface {
ListRecords(ctx context.Context, in *ListRecordsRequest, opts ...grpc.CallOption) (*ListRecordsResponse, error)
CreateRecord(ctx context.Context, in *CreateRecordRequest, opts ...grpc.CallOption) (*Record, error)
GetRecord(ctx context.Context, in *GetRecordRequest, opts ...grpc.CallOption) (*Record, error)
UpdateRecord(ctx context.Context, in *UpdateRecordRequest, opts ...grpc.CallOption) (*Record, error)
DeleteRecord(ctx context.Context, in *DeleteRecordRequest, opts ...grpc.CallOption) (*DeleteRecordResponse, error)
}
type recordServiceClient struct {
cc grpc.ClientConnInterface
}
func NewRecordServiceClient(cc grpc.ClientConnInterface) RecordServiceClient {
return &recordServiceClient{cc}
}
func (c *recordServiceClient) ListRecords(ctx context.Context, in *ListRecordsRequest, opts ...grpc.CallOption) (*ListRecordsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRecordsResponse)
err := c.cc.Invoke(ctx, RecordService_ListRecords_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *recordServiceClient) CreateRecord(ctx context.Context, in *CreateRecordRequest, opts ...grpc.CallOption) (*Record, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Record)
err := c.cc.Invoke(ctx, RecordService_CreateRecord_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *recordServiceClient) GetRecord(ctx context.Context, in *GetRecordRequest, opts ...grpc.CallOption) (*Record, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Record)
err := c.cc.Invoke(ctx, RecordService_GetRecord_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *recordServiceClient) UpdateRecord(ctx context.Context, in *UpdateRecordRequest, opts ...grpc.CallOption) (*Record, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Record)
err := c.cc.Invoke(ctx, RecordService_UpdateRecord_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *recordServiceClient) DeleteRecord(ctx context.Context, in *DeleteRecordRequest, opts ...grpc.CallOption) (*DeleteRecordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteRecordResponse)
err := c.cc.Invoke(ctx, RecordService_DeleteRecord_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RecordServiceServer is the server API for RecordService service.
// All implementations must embed UnimplementedRecordServiceServer
// for forward compatibility.
//
// RecordService manages DNS records within zones.
type RecordServiceServer interface {
ListRecords(context.Context, *ListRecordsRequest) (*ListRecordsResponse, error)
CreateRecord(context.Context, *CreateRecordRequest) (*Record, error)
GetRecord(context.Context, *GetRecordRequest) (*Record, error)
UpdateRecord(context.Context, *UpdateRecordRequest) (*Record, error)
DeleteRecord(context.Context, *DeleteRecordRequest) (*DeleteRecordResponse, error)
mustEmbedUnimplementedRecordServiceServer()
}
// UnimplementedRecordServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedRecordServiceServer struct{}
func (UnimplementedRecordServiceServer) ListRecords(context.Context, *ListRecordsRequest) (*ListRecordsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRecords not implemented")
}
func (UnimplementedRecordServiceServer) CreateRecord(context.Context, *CreateRecordRequest) (*Record, error) {
return nil, status.Error(codes.Unimplemented, "method CreateRecord not implemented")
}
func (UnimplementedRecordServiceServer) GetRecord(context.Context, *GetRecordRequest) (*Record, error) {
return nil, status.Error(codes.Unimplemented, "method GetRecord not implemented")
}
func (UnimplementedRecordServiceServer) UpdateRecord(context.Context, *UpdateRecordRequest) (*Record, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateRecord not implemented")
}
func (UnimplementedRecordServiceServer) DeleteRecord(context.Context, *DeleteRecordRequest) (*DeleteRecordResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteRecord not implemented")
}
func (UnimplementedRecordServiceServer) mustEmbedUnimplementedRecordServiceServer() {}
func (UnimplementedRecordServiceServer) testEmbeddedByValue() {}
// UnsafeRecordServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to RecordServiceServer will
// result in compilation errors.
type UnsafeRecordServiceServer interface {
mustEmbedUnimplementedRecordServiceServer()
}
func RegisterRecordServiceServer(s grpc.ServiceRegistrar, srv RecordServiceServer) {
// If the following call panics, it indicates UnimplementedRecordServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&RecordService_ServiceDesc, srv)
}
func _RecordService_ListRecords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRecordsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RecordServiceServer).ListRecords(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RecordService_ListRecords_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RecordServiceServer).ListRecords(ctx, req.(*ListRecordsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RecordService_CreateRecord_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateRecordRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RecordServiceServer).CreateRecord(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RecordService_CreateRecord_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RecordServiceServer).CreateRecord(ctx, req.(*CreateRecordRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RecordService_GetRecord_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRecordRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RecordServiceServer).GetRecord(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RecordService_GetRecord_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RecordServiceServer).GetRecord(ctx, req.(*GetRecordRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RecordService_UpdateRecord_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateRecordRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RecordServiceServer).UpdateRecord(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RecordService_UpdateRecord_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RecordServiceServer).UpdateRecord(ctx, req.(*UpdateRecordRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RecordService_DeleteRecord_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteRecordRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RecordServiceServer).DeleteRecord(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RecordService_DeleteRecord_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RecordServiceServer).DeleteRecord(ctx, req.(*DeleteRecordRequest))
}
return interceptor(ctx, in, info, handler)
}
// RecordService_ServiceDesc is the grpc.ServiceDesc for RecordService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var RecordService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcns.v1.RecordService",
HandlerType: (*RecordServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListRecords",
Handler: _RecordService_ListRecords_Handler,
},
{
MethodName: "CreateRecord",
Handler: _RecordService_CreateRecord_Handler,
},
{
MethodName: "GetRecord",
Handler: _RecordService_GetRecord_Handler,
},
{
MethodName: "UpdateRecord",
Handler: _RecordService_UpdateRecord_Handler,
},
{
MethodName: "DeleteRecord",
Handler: _RecordService_DeleteRecord_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcns/v1/record.proto",
}

667
gen/mcns/v1/zone.pb.go Normal file
View File

@@ -0,0 +1,667 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: proto/mcns/v1/zone.proto
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Zone struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
PrimaryNs string `protobuf:"bytes,3,opt,name=primary_ns,json=primaryNs,proto3" json:"primary_ns,omitempty"`
AdminEmail string `protobuf:"bytes,4,opt,name=admin_email,json=adminEmail,proto3" json:"admin_email,omitempty"`
Refresh int32 `protobuf:"varint,5,opt,name=refresh,proto3" json:"refresh,omitempty"`
Retry int32 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"`
Expire int32 `protobuf:"varint,7,opt,name=expire,proto3" json:"expire,omitempty"`
MinimumTtl int32 `protobuf:"varint,8,opt,name=minimum_ttl,json=minimumTtl,proto3" json:"minimum_ttl,omitempty"`
Serial int64 `protobuf:"varint,9,opt,name=serial,proto3" json:"serial,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Zone) Reset() {
*x = Zone{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Zone) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Zone) ProtoMessage() {}
func (x *Zone) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Zone.ProtoReflect.Descriptor instead.
func (*Zone) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{0}
}
func (x *Zone) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *Zone) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *Zone) GetPrimaryNs() string {
if x != nil {
return x.PrimaryNs
}
return ""
}
func (x *Zone) GetAdminEmail() string {
if x != nil {
return x.AdminEmail
}
return ""
}
func (x *Zone) GetRefresh() int32 {
if x != nil {
return x.Refresh
}
return 0
}
func (x *Zone) GetRetry() int32 {
if x != nil {
return x.Retry
}
return 0
}
func (x *Zone) GetExpire() int32 {
if x != nil {
return x.Expire
}
return 0
}
func (x *Zone) GetMinimumTtl() int32 {
if x != nil {
return x.MinimumTtl
}
return 0
}
func (x *Zone) GetSerial() int64 {
if x != nil {
return x.Serial
}
return 0
}
func (x *Zone) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *Zone) GetUpdatedAt() *timestamppb.Timestamp {
if x != nil {
return x.UpdatedAt
}
return nil
}
type ListZonesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListZonesRequest) Reset() {
*x = ListZonesRequest{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListZonesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListZonesRequest) ProtoMessage() {}
func (x *ListZonesRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListZonesRequest.ProtoReflect.Descriptor instead.
func (*ListZonesRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{1}
}
type ListZonesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Zones []*Zone `protobuf:"bytes,1,rep,name=zones,proto3" json:"zones,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListZonesResponse) Reset() {
*x = ListZonesResponse{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListZonesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListZonesResponse) ProtoMessage() {}
func (x *ListZonesResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListZonesResponse.ProtoReflect.Descriptor instead.
func (*ListZonesResponse) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{2}
}
func (x *ListZonesResponse) GetZones() []*Zone {
if x != nil {
return x.Zones
}
return nil
}
type CreateZoneRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
PrimaryNs string `protobuf:"bytes,2,opt,name=primary_ns,json=primaryNs,proto3" json:"primary_ns,omitempty"`
AdminEmail string `protobuf:"bytes,3,opt,name=admin_email,json=adminEmail,proto3" json:"admin_email,omitempty"`
Refresh int32 `protobuf:"varint,4,opt,name=refresh,proto3" json:"refresh,omitempty"`
Retry int32 `protobuf:"varint,5,opt,name=retry,proto3" json:"retry,omitempty"`
Expire int32 `protobuf:"varint,6,opt,name=expire,proto3" json:"expire,omitempty"`
MinimumTtl int32 `protobuf:"varint,7,opt,name=minimum_ttl,json=minimumTtl,proto3" json:"minimum_ttl,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateZoneRequest) Reset() {
*x = CreateZoneRequest{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateZoneRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateZoneRequest) ProtoMessage() {}
func (x *CreateZoneRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateZoneRequest.ProtoReflect.Descriptor instead.
func (*CreateZoneRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{3}
}
func (x *CreateZoneRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *CreateZoneRequest) GetPrimaryNs() string {
if x != nil {
return x.PrimaryNs
}
return ""
}
func (x *CreateZoneRequest) GetAdminEmail() string {
if x != nil {
return x.AdminEmail
}
return ""
}
func (x *CreateZoneRequest) GetRefresh() int32 {
if x != nil {
return x.Refresh
}
return 0
}
func (x *CreateZoneRequest) GetRetry() int32 {
if x != nil {
return x.Retry
}
return 0
}
func (x *CreateZoneRequest) GetExpire() int32 {
if x != nil {
return x.Expire
}
return 0
}
func (x *CreateZoneRequest) GetMinimumTtl() int32 {
if x != nil {
return x.MinimumTtl
}
return 0
}
type GetZoneRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetZoneRequest) Reset() {
*x = GetZoneRequest{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetZoneRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetZoneRequest) ProtoMessage() {}
func (x *GetZoneRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetZoneRequest.ProtoReflect.Descriptor instead.
func (*GetZoneRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{4}
}
func (x *GetZoneRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
type UpdateZoneRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
PrimaryNs string `protobuf:"bytes,2,opt,name=primary_ns,json=primaryNs,proto3" json:"primary_ns,omitempty"`
AdminEmail string `protobuf:"bytes,3,opt,name=admin_email,json=adminEmail,proto3" json:"admin_email,omitempty"`
Refresh int32 `protobuf:"varint,4,opt,name=refresh,proto3" json:"refresh,omitempty"`
Retry int32 `protobuf:"varint,5,opt,name=retry,proto3" json:"retry,omitempty"`
Expire int32 `protobuf:"varint,6,opt,name=expire,proto3" json:"expire,omitempty"`
MinimumTtl int32 `protobuf:"varint,7,opt,name=minimum_ttl,json=minimumTtl,proto3" json:"minimum_ttl,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateZoneRequest) Reset() {
*x = UpdateZoneRequest{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateZoneRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateZoneRequest) ProtoMessage() {}
func (x *UpdateZoneRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateZoneRequest.ProtoReflect.Descriptor instead.
func (*UpdateZoneRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{5}
}
func (x *UpdateZoneRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *UpdateZoneRequest) GetPrimaryNs() string {
if x != nil {
return x.PrimaryNs
}
return ""
}
func (x *UpdateZoneRequest) GetAdminEmail() string {
if x != nil {
return x.AdminEmail
}
return ""
}
func (x *UpdateZoneRequest) GetRefresh() int32 {
if x != nil {
return x.Refresh
}
return 0
}
func (x *UpdateZoneRequest) GetRetry() int32 {
if x != nil {
return x.Retry
}
return 0
}
func (x *UpdateZoneRequest) GetExpire() int32 {
if x != nil {
return x.Expire
}
return 0
}
func (x *UpdateZoneRequest) GetMinimumTtl() int32 {
if x != nil {
return x.MinimumTtl
}
return 0
}
type DeleteZoneRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteZoneRequest) Reset() {
*x = DeleteZoneRequest{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteZoneRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteZoneRequest) ProtoMessage() {}
func (x *DeleteZoneRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteZoneRequest.ProtoReflect.Descriptor instead.
func (*DeleteZoneRequest) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{6}
}
func (x *DeleteZoneRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
type DeleteZoneResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteZoneResponse) Reset() {
*x = DeleteZoneResponse{}
mi := &file_proto_mcns_v1_zone_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteZoneResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteZoneResponse) ProtoMessage() {}
func (x *DeleteZoneResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_mcns_v1_zone_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteZoneResponse.ProtoReflect.Descriptor instead.
func (*DeleteZoneResponse) Descriptor() ([]byte, []int) {
return file_proto_mcns_v1_zone_proto_rawDescGZIP(), []int{7}
}
var File_proto_mcns_v1_zone_proto protoreflect.FileDescriptor
const file_proto_mcns_v1_zone_proto_rawDesc = "" +
"\n" +
"\x18proto/mcns/v1/zone.proto\x12\amcns.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe1\x02\n" +
"\x04Zone\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" +
"\n" +
"primary_ns\x18\x03 \x01(\tR\tprimaryNs\x12\x1f\n" +
"\vadmin_email\x18\x04 \x01(\tR\n" +
"adminEmail\x12\x18\n" +
"\arefresh\x18\x05 \x01(\x05R\arefresh\x12\x14\n" +
"\x05retry\x18\x06 \x01(\x05R\x05retry\x12\x16\n" +
"\x06expire\x18\a \x01(\x05R\x06expire\x12\x1f\n" +
"\vminimum_ttl\x18\b \x01(\x05R\n" +
"minimumTtl\x12\x16\n" +
"\x06serial\x18\t \x01(\x03R\x06serial\x129\n" +
"\n" +
"created_at\x18\n" +
" \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
"\n" +
"updated_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\x12\n" +
"\x10ListZonesRequest\"8\n" +
"\x11ListZonesResponse\x12#\n" +
"\x05zones\x18\x01 \x03(\v2\r.mcns.v1.ZoneR\x05zones\"\xd0\x01\n" +
"\x11CreateZoneRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" +
"\n" +
"primary_ns\x18\x02 \x01(\tR\tprimaryNs\x12\x1f\n" +
"\vadmin_email\x18\x03 \x01(\tR\n" +
"adminEmail\x12\x18\n" +
"\arefresh\x18\x04 \x01(\x05R\arefresh\x12\x14\n" +
"\x05retry\x18\x05 \x01(\x05R\x05retry\x12\x16\n" +
"\x06expire\x18\x06 \x01(\x05R\x06expire\x12\x1f\n" +
"\vminimum_ttl\x18\a \x01(\x05R\n" +
"minimumTtl\"$\n" +
"\x0eGetZoneRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\"\xd0\x01\n" +
"\x11UpdateZoneRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" +
"\n" +
"primary_ns\x18\x02 \x01(\tR\tprimaryNs\x12\x1f\n" +
"\vadmin_email\x18\x03 \x01(\tR\n" +
"adminEmail\x12\x18\n" +
"\arefresh\x18\x04 \x01(\x05R\arefresh\x12\x14\n" +
"\x05retry\x18\x05 \x01(\x05R\x05retry\x12\x16\n" +
"\x06expire\x18\x06 \x01(\x05R\x06expire\x12\x1f\n" +
"\vminimum_ttl\x18\a \x01(\x05R\n" +
"minimumTtl\"'\n" +
"\x11DeleteZoneRequest\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\"\x14\n" +
"\x12DeleteZoneResponse2\xbd\x02\n" +
"\vZoneService\x12B\n" +
"\tListZones\x12\x19.mcns.v1.ListZonesRequest\x1a\x1a.mcns.v1.ListZonesResponse\x127\n" +
"\n" +
"CreateZone\x12\x1a.mcns.v1.CreateZoneRequest\x1a\r.mcns.v1.Zone\x121\n" +
"\aGetZone\x12\x17.mcns.v1.GetZoneRequest\x1a\r.mcns.v1.Zone\x127\n" +
"\n" +
"UpdateZone\x12\x1a.mcns.v1.UpdateZoneRequest\x1a\r.mcns.v1.Zone\x12E\n" +
"\n" +
"DeleteZone\x12\x1a.mcns.v1.DeleteZoneRequest\x1a\x1b.mcns.v1.DeleteZoneResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_zone_proto_rawDescOnce sync.Once
file_proto_mcns_v1_zone_proto_rawDescData []byte
)
func file_proto_mcns_v1_zone_proto_rawDescGZIP() []byte {
file_proto_mcns_v1_zone_proto_rawDescOnce.Do(func() {
file_proto_mcns_v1_zone_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_zone_proto_rawDesc), len(file_proto_mcns_v1_zone_proto_rawDesc)))
})
return file_proto_mcns_v1_zone_proto_rawDescData
}
var file_proto_mcns_v1_zone_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_proto_mcns_v1_zone_proto_goTypes = []any{
(*Zone)(nil), // 0: mcns.v1.Zone
(*ListZonesRequest)(nil), // 1: mcns.v1.ListZonesRequest
(*ListZonesResponse)(nil), // 2: mcns.v1.ListZonesResponse
(*CreateZoneRequest)(nil), // 3: mcns.v1.CreateZoneRequest
(*GetZoneRequest)(nil), // 4: mcns.v1.GetZoneRequest
(*UpdateZoneRequest)(nil), // 5: mcns.v1.UpdateZoneRequest
(*DeleteZoneRequest)(nil), // 6: mcns.v1.DeleteZoneRequest
(*DeleteZoneResponse)(nil), // 7: mcns.v1.DeleteZoneResponse
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
}
var file_proto_mcns_v1_zone_proto_depIdxs = []int32{
8, // 0: mcns.v1.Zone.created_at:type_name -> google.protobuf.Timestamp
8, // 1: mcns.v1.Zone.updated_at:type_name -> google.protobuf.Timestamp
0, // 2: mcns.v1.ListZonesResponse.zones:type_name -> mcns.v1.Zone
1, // 3: mcns.v1.ZoneService.ListZones:input_type -> mcns.v1.ListZonesRequest
3, // 4: mcns.v1.ZoneService.CreateZone:input_type -> mcns.v1.CreateZoneRequest
4, // 5: mcns.v1.ZoneService.GetZone:input_type -> mcns.v1.GetZoneRequest
5, // 6: mcns.v1.ZoneService.UpdateZone:input_type -> mcns.v1.UpdateZoneRequest
6, // 7: mcns.v1.ZoneService.DeleteZone:input_type -> mcns.v1.DeleteZoneRequest
2, // 8: mcns.v1.ZoneService.ListZones:output_type -> mcns.v1.ListZonesResponse
0, // 9: mcns.v1.ZoneService.CreateZone:output_type -> mcns.v1.Zone
0, // 10: mcns.v1.ZoneService.GetZone:output_type -> mcns.v1.Zone
0, // 11: mcns.v1.ZoneService.UpdateZone:output_type -> mcns.v1.Zone
7, // 12: mcns.v1.ZoneService.DeleteZone:output_type -> mcns.v1.DeleteZoneResponse
8, // [8:13] is the sub-list for method output_type
3, // [3:8] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_proto_mcns_v1_zone_proto_init() }
func file_proto_mcns_v1_zone_proto_init() {
if File_proto_mcns_v1_zone_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_mcns_v1_zone_proto_rawDesc), len(file_proto_mcns_v1_zone_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_proto_mcns_v1_zone_proto_goTypes,
DependencyIndexes: file_proto_mcns_v1_zone_proto_depIdxs,
MessageInfos: file_proto_mcns_v1_zone_proto_msgTypes,
}.Build()
File_proto_mcns_v1_zone_proto = out.File
file_proto_mcns_v1_zone_proto_goTypes = nil
file_proto_mcns_v1_zone_proto_depIdxs = nil
}

277
gen/mcns/v1/zone_grpc.pb.go Normal file
View File

@@ -0,0 +1,277 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: proto/mcns/v1/zone.proto
package mcnsv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ZoneService_ListZones_FullMethodName = "/mcns.v1.ZoneService/ListZones"
ZoneService_CreateZone_FullMethodName = "/mcns.v1.ZoneService/CreateZone"
ZoneService_GetZone_FullMethodName = "/mcns.v1.ZoneService/GetZone"
ZoneService_UpdateZone_FullMethodName = "/mcns.v1.ZoneService/UpdateZone"
ZoneService_DeleteZone_FullMethodName = "/mcns.v1.ZoneService/DeleteZone"
)
// ZoneServiceClient is the client API for ZoneService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// ZoneService manages DNS zones and their SOA parameters.
type ZoneServiceClient interface {
ListZones(ctx context.Context, in *ListZonesRequest, opts ...grpc.CallOption) (*ListZonesResponse, error)
CreateZone(ctx context.Context, in *CreateZoneRequest, opts ...grpc.CallOption) (*Zone, error)
GetZone(ctx context.Context, in *GetZoneRequest, opts ...grpc.CallOption) (*Zone, error)
UpdateZone(ctx context.Context, in *UpdateZoneRequest, opts ...grpc.CallOption) (*Zone, error)
DeleteZone(ctx context.Context, in *DeleteZoneRequest, opts ...grpc.CallOption) (*DeleteZoneResponse, error)
}
type zoneServiceClient struct {
cc grpc.ClientConnInterface
}
func NewZoneServiceClient(cc grpc.ClientConnInterface) ZoneServiceClient {
return &zoneServiceClient{cc}
}
func (c *zoneServiceClient) ListZones(ctx context.Context, in *ListZonesRequest, opts ...grpc.CallOption) (*ListZonesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListZonesResponse)
err := c.cc.Invoke(ctx, ZoneService_ListZones_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *zoneServiceClient) CreateZone(ctx context.Context, in *CreateZoneRequest, opts ...grpc.CallOption) (*Zone, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Zone)
err := c.cc.Invoke(ctx, ZoneService_CreateZone_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *zoneServiceClient) GetZone(ctx context.Context, in *GetZoneRequest, opts ...grpc.CallOption) (*Zone, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Zone)
err := c.cc.Invoke(ctx, ZoneService_GetZone_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *zoneServiceClient) UpdateZone(ctx context.Context, in *UpdateZoneRequest, opts ...grpc.CallOption) (*Zone, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Zone)
err := c.cc.Invoke(ctx, ZoneService_UpdateZone_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *zoneServiceClient) DeleteZone(ctx context.Context, in *DeleteZoneRequest, opts ...grpc.CallOption) (*DeleteZoneResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteZoneResponse)
err := c.cc.Invoke(ctx, ZoneService_DeleteZone_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ZoneServiceServer is the server API for ZoneService service.
// All implementations must embed UnimplementedZoneServiceServer
// for forward compatibility.
//
// ZoneService manages DNS zones and their SOA parameters.
type ZoneServiceServer interface {
ListZones(context.Context, *ListZonesRequest) (*ListZonesResponse, error)
CreateZone(context.Context, *CreateZoneRequest) (*Zone, error)
GetZone(context.Context, *GetZoneRequest) (*Zone, error)
UpdateZone(context.Context, *UpdateZoneRequest) (*Zone, error)
DeleteZone(context.Context, *DeleteZoneRequest) (*DeleteZoneResponse, error)
mustEmbedUnimplementedZoneServiceServer()
}
// UnimplementedZoneServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedZoneServiceServer struct{}
func (UnimplementedZoneServiceServer) ListZones(context.Context, *ListZonesRequest) (*ListZonesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListZones not implemented")
}
func (UnimplementedZoneServiceServer) CreateZone(context.Context, *CreateZoneRequest) (*Zone, error) {
return nil, status.Error(codes.Unimplemented, "method CreateZone not implemented")
}
func (UnimplementedZoneServiceServer) GetZone(context.Context, *GetZoneRequest) (*Zone, error) {
return nil, status.Error(codes.Unimplemented, "method GetZone not implemented")
}
func (UnimplementedZoneServiceServer) UpdateZone(context.Context, *UpdateZoneRequest) (*Zone, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateZone not implemented")
}
func (UnimplementedZoneServiceServer) DeleteZone(context.Context, *DeleteZoneRequest) (*DeleteZoneResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteZone not implemented")
}
func (UnimplementedZoneServiceServer) mustEmbedUnimplementedZoneServiceServer() {}
func (UnimplementedZoneServiceServer) testEmbeddedByValue() {}
// UnsafeZoneServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ZoneServiceServer will
// result in compilation errors.
type UnsafeZoneServiceServer interface {
mustEmbedUnimplementedZoneServiceServer()
}
func RegisterZoneServiceServer(s grpc.ServiceRegistrar, srv ZoneServiceServer) {
// If the following call panics, it indicates UnimplementedZoneServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ZoneService_ServiceDesc, srv)
}
func _ZoneService_ListZones_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListZonesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ZoneServiceServer).ListZones(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ZoneService_ListZones_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ZoneServiceServer).ListZones(ctx, req.(*ListZonesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ZoneService_CreateZone_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateZoneRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ZoneServiceServer).CreateZone(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ZoneService_CreateZone_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ZoneServiceServer).CreateZone(ctx, req.(*CreateZoneRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ZoneService_GetZone_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetZoneRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ZoneServiceServer).GetZone(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ZoneService_GetZone_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ZoneServiceServer).GetZone(ctx, req.(*GetZoneRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ZoneService_UpdateZone_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateZoneRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ZoneServiceServer).UpdateZone(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ZoneService_UpdateZone_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ZoneServiceServer).UpdateZone(ctx, req.(*UpdateZoneRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ZoneService_DeleteZone_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteZoneRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ZoneServiceServer).DeleteZone(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ZoneService_DeleteZone_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ZoneServiceServer).DeleteZone(ctx, req.(*DeleteZoneRequest))
}
return interceptor(ctx, in, info, handler)
}
// ZoneService_ServiceDesc is the grpc.ServiceDesc for ZoneService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ZoneService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcns.v1.ZoneService",
HandlerType: (*ZoneServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListZones",
Handler: _ZoneService_ListZones_Handler,
},
{
MethodName: "CreateZone",
Handler: _ZoneService_CreateZone_Handler,
},
{
MethodName: "GetZone",
Handler: _ZoneService_GetZone_Handler,
},
{
MethodName: "UpdateZone",
Handler: _ZoneService_UpdateZone_Handler,
},
{
MethodName: "DeleteZone",
Handler: _ZoneService_DeleteZone_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/mcns/v1/zone.proto",
}

34
go.mod Normal file
View File

@@ -0,0 +1,34 @@
module git.wntrmute.dev/mc/mcns
go 1.25.7
require (
git.wntrmute.dev/mc/mcdsl v1.2.0
github.com/go-chi/chi/v5 v5.2.5
github.com/miekg/dns v1.1.66
github.com/spf13/cobra v1.10.2
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)

103
go.sum Normal file
View File

@@ -0,0 +1,103 @@
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

55
internal/config/config.go Normal file
View File

@@ -0,0 +1,55 @@
package config
import (
"fmt"
mcdslconfig "git.wntrmute.dev/mc/mcdsl/config"
)
// Config is the top-level MCNS configuration.
type Config struct {
mcdslconfig.Base
DNS DNSConfig `toml:"dns"`
}
// DNSConfig holds the DNS server settings.
type DNSConfig struct {
ListenAddr string `toml:"listen_addr"`
Upstreams []string `toml:"upstreams"`
}
// Load reads a TOML config file, applies environment variable overrides
// (MCNS_ prefix), sets defaults, and validates required fields.
func Load(path string) (*Config, error) {
cfg, err := mcdslconfig.Load[Config](path, "MCNS")
if err != nil {
return nil, err
}
// Apply DNS defaults.
if cfg.DNS.ListenAddr == "" {
cfg.DNS.ListenAddr = ":53"
}
if len(cfg.DNS.Upstreams) == 0 {
cfg.DNS.Upstreams = []string{"1.1.1.1:53", "8.8.8.8:53"}
}
return cfg, nil
}
// Validate implements the mcdsl config.Validator interface.
func (c *Config) Validate() error {
if c.Database.Path == "" {
return fmt.Errorf("database.path is required")
}
if c.MCIAS.ServerURL == "" {
return fmt.Errorf("mcias.server_url is required")
}
if c.Server.TLSCert == "" {
return fmt.Errorf("server.tls_cert is required")
}
if c.Server.TLSKey == "" {
return fmt.Errorf("server.tls_key is required")
}
return nil
}

23
internal/db/db.go Normal file
View File

@@ -0,0 +1,23 @@
package db
import (
"database/sql"
"fmt"
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
)
// DB wraps a SQLite database connection.
type DB struct {
*sql.DB
}
// Open opens (or creates) a SQLite database at the given path with the
// standard Metacircular pragmas: WAL mode, foreign keys, busy timeout.
func Open(path string) (*DB, error) {
sqlDB, err := mcdsldb.Open(path)
if err != nil {
return nil, fmt.Errorf("db: %w", err)
}
return &DB{sqlDB}, nil
}

74
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,74 @@
package db
import (
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
)
// Migrations is the ordered list of MCNS schema migrations.
var Migrations = []mcdsldb.Migration{
{
Version: 1,
Name: "zones and records",
SQL: `
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS idx_records_zone_name ON records(zone_id, name);`,
},
{
Version: 2,
Name: "seed zones and records from CoreDNS zone files",
SQL: `
-- Zone: svc.mcp.metacircular.net (service addresses)
INSERT OR IGNORE INTO zones (id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial)
VALUES (1, 'svc.mcp.metacircular.net', 'ns.mcp.metacircular.net.', 'admin.metacircular.net.', 3600, 600, 86400, 300, 2026032601);
-- Zone: mcp.metacircular.net (node addresses)
INSERT OR IGNORE INTO zones (id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial)
VALUES (2, 'mcp.metacircular.net', 'ns.mcp.metacircular.net.', 'admin.metacircular.net.', 3600, 600, 86400, 300, 2026032501);
-- svc.mcp.metacircular.net records
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'metacrypt', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'metacrypt', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcr', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcr', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'sgard', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'sgard', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcp-agent', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcp-agent', 'A', '100.95.252.120', 300);
-- mcp.metacircular.net records
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'rift', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'rift', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'ns', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'ns', 'A', '100.95.252.120', 300);`,
},
}
// Migrate applies all pending migrations.
func (d *DB) Migrate() error {
return mcdsldb.Migrate(d.DB, Migrations)
}

View File

@@ -0,0 +1,47 @@
package db
import (
"path/filepath"
"testing"
)
func TestMigrateIdempotent(t *testing.T) {
dir := t.TempDir()
database, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.Migrate(); err != nil {
t.Fatalf("first migrate: %v", err)
}
if err := database.Migrate(); err != nil {
t.Fatalf("second migrate: %v", err)
}
// Verify seed data is present exactly once.
zones, err := database.ListZones()
if err != nil {
t.Fatalf("list zones: %v", err)
}
if len(zones) != 2 {
t.Fatalf("got %d zones, want 2", len(zones))
}
records, err := database.ListRecords("svc.mcp.metacircular.net", "", "")
if err != nil {
t.Fatalf("list records: %v", err)
}
if len(records) != 8 {
t.Fatalf("got %d svc records, want 8", len(records))
}
records, err = database.ListRecords("mcp.metacircular.net", "", "")
if err != nil {
t.Fatalf("list records: %v", err)
}
if len(records) != 4 {
t.Fatalf("got %d mcp records, want 4", len(records))
}
}

308
internal/db/records.go Normal file
View File

@@ -0,0 +1,308 @@
package db
import (
"database/sql"
"errors"
"fmt"
"net"
"strings"
"time"
)
// Record represents a DNS record stored in the database.
type Record struct {
ID int64
ZoneID int64
ZoneName string
Name string
Type string
Value string
TTL int
CreatedAt string
UpdatedAt string
}
// ListRecords returns records for a zone, optionally filtered by name and type.
func (d *DB) ListRecords(zoneName, name, recordType string) ([]Record, error) {
zoneName = strings.ToLower(strings.TrimSuffix(zoneName, "."))
zone, err := d.GetZone(zoneName)
if err != nil {
return nil, err
}
query := `SELECT r.id, r.zone_id, z.name, r.name, r.type, r.value, r.ttl, r.created_at, r.updated_at
FROM records r JOIN zones z ON r.zone_id = z.id WHERE r.zone_id = ?`
args := []any{zone.ID}
if name != "" {
query += ` AND r.name = ?`
args = append(args, strings.ToLower(name))
}
if recordType != "" {
query += ` AND r.type = ?`
args = append(args, strings.ToUpper(recordType))
}
query += ` ORDER BY r.name, r.type, r.value`
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
defer rows.Close()
var records []Record
for rows.Next() {
var r Record
if err := rows.Scan(&r.ID, &r.ZoneID, &r.ZoneName, &r.Name, &r.Type, &r.Value, &r.TTL, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan record: %w", err)
}
records = append(records, r)
}
return records, rows.Err()
}
// LookupRecords returns records matching a name and type within a zone.
// Used by the DNS handler for query resolution.
func (d *DB) LookupRecords(zoneName, name, recordType string) ([]Record, error) {
zoneName = strings.ToLower(strings.TrimSuffix(zoneName, "."))
name = strings.ToLower(name)
rows, err := d.Query(`SELECT r.id, r.zone_id, z.name, r.name, r.type, r.value, r.ttl, r.created_at, r.updated_at
FROM records r JOIN zones z ON r.zone_id = z.id
WHERE z.name = ? AND r.name = ? AND r.type = ?`, zoneName, name, strings.ToUpper(recordType))
if err != nil {
return nil, fmt.Errorf("lookup records: %w", err)
}
defer rows.Close()
var records []Record
for rows.Next() {
var r Record
if err := rows.Scan(&r.ID, &r.ZoneID, &r.ZoneName, &r.Name, &r.Type, &r.Value, &r.TTL, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan record: %w", err)
}
records = append(records, r)
}
return records, rows.Err()
}
// LookupCNAME returns CNAME records for a name within a zone.
func (d *DB) LookupCNAME(zoneName, name string) ([]Record, error) {
return d.LookupRecords(zoneName, name, "CNAME")
}
// HasRecordsForName checks if any records of the given types exist for a name.
func (d *DB) HasRecordsForName(tx *sql.Tx, zoneID int64, name string, types []string) (bool, error) {
placeholders := make([]string, len(types))
args := []any{zoneID, strings.ToLower(name)}
for i, t := range types {
placeholders[i] = "?"
args = append(args, strings.ToUpper(t))
}
query := fmt.Sprintf(`SELECT COUNT(*) FROM records WHERE zone_id = ? AND name = ? AND type IN (%s)`, strings.Join(placeholders, ","))
var count int
if err := tx.QueryRow(query, args...).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
// GetRecord returns a record by ID.
func (d *DB) GetRecord(id int64) (*Record, error) {
var r Record
err := d.QueryRow(`SELECT r.id, r.zone_id, z.name, r.name, r.type, r.value, r.ttl, r.created_at, r.updated_at
FROM records r JOIN zones z ON r.zone_id = z.id WHERE r.id = ?`, id).
Scan(&r.ID, &r.ZoneID, &r.ZoneName, &r.Name, &r.Type, &r.Value, &r.TTL, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get record: %w", err)
}
return &r, nil
}
// CreateRecord inserts a new record, enforcing CNAME exclusivity and
// value validation. Bumps the zone serial within the same transaction.
func (d *DB) CreateRecord(zoneName, name, recordType, value string, ttl int) (*Record, error) {
zoneName = strings.ToLower(strings.TrimSuffix(zoneName, "."))
name = strings.ToLower(name)
recordType = strings.ToUpper(recordType)
if err := validateRecordValue(recordType, value); err != nil {
return nil, err
}
zone, err := d.GetZone(zoneName)
if err != nil {
return nil, err
}
if ttl <= 0 {
ttl = 300
}
tx, err := d.Begin()
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Enforce CNAME exclusivity.
if recordType == "CNAME" {
hasAddr, err := d.HasRecordsForName(tx, zone.ID, name, []string{"A", "AAAA"})
if err != nil {
return nil, fmt.Errorf("check cname exclusivity: %w", err)
}
if hasAddr {
return nil, fmt.Errorf("%w: CNAME record conflicts with existing A/AAAA record for %q", ErrConflict, name)
}
} else if recordType == "A" || recordType == "AAAA" {
hasCNAME, err := d.HasRecordsForName(tx, zone.ID, name, []string{"CNAME"})
if err != nil {
return nil, fmt.Errorf("check cname exclusivity: %w", err)
}
if hasCNAME {
return nil, fmt.Errorf("%w: A/AAAA record conflicts with existing CNAME record for %q", ErrConflict, name)
}
}
res, err := tx.Exec(`INSERT INTO records (zone_id, name, type, value, ttl) VALUES (?, ?, ?, ?, ?)`,
zone.ID, name, recordType, value, ttl)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
return nil, fmt.Errorf("%w: record already exists", ErrConflict)
}
return nil, fmt.Errorf("insert record: %w", err)
}
recordID, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("last insert id: %w", err)
}
if err := d.BumpSerial(tx, zone.ID); err != nil {
return nil, fmt.Errorf("bump serial: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return d.GetRecord(recordID)
}
// UpdateRecord updates an existing record's fields and bumps the zone serial.
func (d *DB) UpdateRecord(id int64, name, recordType, value string, ttl int) (*Record, error) {
name = strings.ToLower(name)
recordType = strings.ToUpper(recordType)
if err := validateRecordValue(recordType, value); err != nil {
return nil, err
}
existing, err := d.GetRecord(id)
if err != nil {
return nil, err
}
if ttl <= 0 {
ttl = 300
}
tx, err := d.Begin()
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Enforce CNAME exclusivity for the new type/name combo.
if recordType == "CNAME" {
hasAddr, err := d.HasRecordsForName(tx, existing.ZoneID, name, []string{"A", "AAAA"})
if err != nil {
return nil, fmt.Errorf("check cname exclusivity: %w", err)
}
if hasAddr {
return nil, fmt.Errorf("%w: CNAME record conflicts with existing A/AAAA record for %q", ErrConflict, name)
}
} else if recordType == "A" || recordType == "AAAA" {
hasCNAME, err := d.HasRecordsForName(tx, existing.ZoneID, name, []string{"CNAME"})
if err != nil {
return nil, fmt.Errorf("check cname exclusivity: %w", err)
}
if hasCNAME {
return nil, fmt.Errorf("%w: A/AAAA record conflicts with existing CNAME record for %q", ErrConflict, name)
}
}
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
_, err = tx.Exec(`UPDATE records SET name = ?, type = ?, value = ?, ttl = ?, updated_at = ? WHERE id = ?`,
name, recordType, value, ttl, now, id)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
return nil, fmt.Errorf("%w: record already exists", ErrConflict)
}
return nil, fmt.Errorf("update record: %w", err)
}
if err := d.BumpSerial(tx, existing.ZoneID); err != nil {
return nil, fmt.Errorf("bump serial: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return d.GetRecord(id)
}
// DeleteRecord deletes a record and bumps the zone serial.
func (d *DB) DeleteRecord(id int64) error {
existing, err := d.GetRecord(id)
if err != nil {
return err
}
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(`DELETE FROM records WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete record: %w", err)
}
if err := d.BumpSerial(tx, existing.ZoneID); err != nil {
return fmt.Errorf("bump serial: %w", err)
}
return tx.Commit()
}
// validateRecordValue checks that a record value is valid for its type.
func validateRecordValue(recordType, value string) error {
switch recordType {
case "A":
ip := net.ParseIP(value)
if ip == nil || ip.To4() == nil {
return fmt.Errorf("invalid IPv4 address: %q", value)
}
case "AAAA":
ip := net.ParseIP(value)
if ip == nil || ip.To4() != nil {
return fmt.Errorf("invalid IPv6 address: %q", value)
}
case "CNAME":
if !strings.HasSuffix(value, ".") {
return fmt.Errorf("CNAME value must be a fully-qualified domain name ending with '.': %q", value)
}
default:
return fmt.Errorf("unsupported record type: %q", recordType)
}
return nil
}

289
internal/db/records_test.go Normal file
View File

@@ -0,0 +1,289 @@
package db
import (
"errors"
"testing"
)
func createTestZone(t *testing.T, db *DB) *Zone {
t.Helper()
zone, err := db.CreateZone("test.example.com", "ns.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
return zone
}
func TestCreateRecordA(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
record, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
if record.Name != "metacrypt" {
t.Fatalf("got name %q, want %q", record.Name, "metacrypt")
}
if record.Type != "A" {
t.Fatalf("got type %q, want %q", record.Type, "A")
}
if record.Value != "192.168.88.181" {
t.Fatalf("got value %q, want %q", record.Value, "192.168.88.181")
}
}
func TestCreateRecordAAAA(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
record, err := db.CreateRecord("test.example.com", "metacrypt", "AAAA", "2001:db8::1", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
if record.Type != "AAAA" {
t.Fatalf("got type %q, want %q", record.Type, "AAAA")
}
}
func TestCreateRecordCNAME(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
record, err := db.CreateRecord("test.example.com", "alias", "CNAME", "rift.mcp.metacircular.net.", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
if record.Type != "CNAME" {
t.Fatalf("got type %q, want %q", record.Type, "CNAME")
}
}
func TestCreateRecordInvalidIP(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
_, err := db.CreateRecord("test.example.com", "bad", "A", "not-an-ip", 300)
if err == nil {
t.Fatal("expected error for invalid IPv4")
}
}
func TestCreateRecordCNAMEExclusivity(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
// Create an A record first.
_, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create A record: %v", err)
}
// Trying to add a CNAME for the same name should fail.
_, err = db.CreateRecord("test.example.com", "metacrypt", "CNAME", "rift.mcp.metacircular.net.", 300)
if !errors.Is(err, ErrConflict) {
t.Fatalf("expected ErrConflict, got %v", err)
}
}
func TestCreateRecordCNAMEExclusivityReverse(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
// Create a CNAME record first.
_, err := db.CreateRecord("test.example.com", "alias", "CNAME", "rift.mcp.metacircular.net.", 300)
if err != nil {
t.Fatalf("create CNAME record: %v", err)
}
// Trying to add an A record for the same name should fail.
_, err = db.CreateRecord("test.example.com", "alias", "A", "192.168.88.181", 300)
if !errors.Is(err, ErrConflict) {
t.Fatalf("expected ErrConflict, got %v", err)
}
}
func TestCreateRecordBumpsSerial(t *testing.T) {
db := openTestDB(t)
zone := createTestZone(t, db)
originalSerial := zone.Serial
_, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
updated, err := db.GetZone("test.example.com")
if err != nil {
t.Fatalf("get zone: %v", err)
}
if updated.Serial <= originalSerial {
t.Fatalf("serial should have bumped: %d <= %d", updated.Serial, originalSerial)
}
}
func TestListRecords(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
_, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record 1: %v", err)
}
_, err = db.CreateRecord("test.example.com", "metacrypt", "A", "100.95.252.120", 300)
if err != nil {
t.Fatalf("create record 2: %v", err)
}
_, err = db.CreateRecord("test.example.com", "mcr", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record 3: %v", err)
}
// List all records.
records, err := db.ListRecords("test.example.com", "", "")
if err != nil {
t.Fatalf("list records: %v", err)
}
if len(records) != 3 {
t.Fatalf("got %d records, want 3", len(records))
}
// Filter by name.
records, err = db.ListRecords("test.example.com", "metacrypt", "")
if err != nil {
t.Fatalf("list records by name: %v", err)
}
if len(records) != 2 {
t.Fatalf("got %d records, want 2", len(records))
}
// Filter by type.
records, err = db.ListRecords("test.example.com", "", "A")
if err != nil {
t.Fatalf("list records by type: %v", err)
}
if len(records) != 3 {
t.Fatalf("got %d records, want 3", len(records))
}
}
func TestUpdateRecord(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
record, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
updated, err := db.UpdateRecord(record.ID, "metacrypt", "A", "10.0.0.1", 600)
if err != nil {
t.Fatalf("update record: %v", err)
}
if updated.Value != "10.0.0.1" {
t.Fatalf("got value %q, want %q", updated.Value, "10.0.0.1")
}
if updated.TTL != 600 {
t.Fatalf("got ttl %d, want 600", updated.TTL)
}
}
func TestDeleteRecord(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
record, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
if err := db.DeleteRecord(record.ID); err != nil {
t.Fatalf("delete record: %v", err)
}
_, err = db.GetRecord(record.ID)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
}
func TestDeleteRecordBumpsSerial(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
record, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
zone, err := db.GetZone("test.example.com")
if err != nil {
t.Fatalf("get zone: %v", err)
}
serialBefore := zone.Serial
if err := db.DeleteRecord(record.ID); err != nil {
t.Fatalf("delete record: %v", err)
}
zone, err = db.GetZone("test.example.com")
if err != nil {
t.Fatalf("get zone after delete: %v", err)
}
if zone.Serial <= serialBefore {
t.Fatalf("serial should have bumped: %d <= %d", zone.Serial, serialBefore)
}
}
func TestLookupRecords(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
_, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
records, err := db.LookupRecords("test.example.com", "metacrypt", "A")
if err != nil {
t.Fatalf("lookup records: %v", err)
}
if len(records) != 1 {
t.Fatalf("got %d records, want 1", len(records))
}
if records[0].Value != "192.168.88.181" {
t.Fatalf("got value %q, want %q", records[0].Value, "192.168.88.181")
}
}
func TestCreateRecordCNAMEMissingDot(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
_, err := db.CreateRecord("test.example.com", "alias", "CNAME", "rift.mcp.metacircular.net", 300)
if err == nil {
t.Fatal("expected error for CNAME without trailing dot")
}
}
func TestMultipleARecords(t *testing.T) {
db := openTestDB(t)
createTestZone(t, db)
_, err := db.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create first A record: %v", err)
}
_, err = db.CreateRecord("test.example.com", "metacrypt", "A", "100.95.252.120", 300)
if err != nil {
t.Fatalf("create second A record: %v", err)
}
records, err := db.LookupRecords("test.example.com", "metacrypt", "A")
if err != nil {
t.Fatalf("lookup records: %v", err)
}
if len(records) != 2 {
t.Fatalf("got %d records, want 2", len(records))
}
}

200
internal/db/zones.go Normal file
View File

@@ -0,0 +1,200 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// Zone represents a DNS zone stored in the database.
type Zone struct {
ID int64
Name string
PrimaryNS string
AdminEmail string
Refresh int
Retry int
Expire int
MinimumTTL int
Serial int64
CreatedAt string
UpdatedAt string
}
// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("not found")
// ErrConflict is returned when a write conflicts with existing data.
var ErrConflict = errors.New("conflict")
// ListZones returns all zones ordered by name.
func (d *DB) ListZones() ([]Zone, error) {
rows, err := d.Query(`SELECT id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial, created_at, updated_at FROM zones ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list zones: %w", err)
}
defer rows.Close()
var zones []Zone
for rows.Next() {
var z Zone
if err := rows.Scan(&z.ID, &z.Name, &z.PrimaryNS, &z.AdminEmail, &z.Refresh, &z.Retry, &z.Expire, &z.MinimumTTL, &z.Serial, &z.CreatedAt, &z.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan zone: %w", err)
}
zones = append(zones, z)
}
return zones, rows.Err()
}
// GetZone returns a zone by name (case-insensitive).
func (d *DB) GetZone(name string) (*Zone, error) {
name = strings.ToLower(strings.TrimSuffix(name, "."))
var z Zone
err := d.QueryRow(`SELECT id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial, created_at, updated_at FROM zones WHERE name = ?`, name).
Scan(&z.ID, &z.Name, &z.PrimaryNS, &z.AdminEmail, &z.Refresh, &z.Retry, &z.Expire, &z.MinimumTTL, &z.Serial, &z.CreatedAt, &z.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get zone: %w", err)
}
return &z, nil
}
// GetZoneByID returns a zone by ID.
func (d *DB) GetZoneByID(id int64) (*Zone, error) {
var z Zone
err := d.QueryRow(`SELECT id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial, created_at, updated_at FROM zones WHERE id = ?`, id).
Scan(&z.ID, &z.Name, &z.PrimaryNS, &z.AdminEmail, &z.Refresh, &z.Retry, &z.Expire, &z.MinimumTTL, &z.Serial, &z.CreatedAt, &z.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get zone by id: %w", err)
}
return &z, nil
}
// CreateZone inserts a new zone and returns it with the generated serial.
func (d *DB) CreateZone(name, primaryNS, adminEmail string, refresh, retry, expire, minimumTTL int) (*Zone, error) {
name = strings.ToLower(strings.TrimSuffix(name, "."))
serial := nextSerial(0)
res, err := d.Exec(`INSERT INTO zones (name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
name, primaryNS, adminEmail, refresh, retry, expire, minimumTTL, serial)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
return nil, fmt.Errorf("%w: zone %q already exists", ErrConflict, name)
}
return nil, fmt.Errorf("create zone: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("create zone: last insert id: %w", err)
}
return d.GetZoneByID(id)
}
// UpdateZone updates a zone's SOA parameters and bumps the serial.
func (d *DB) UpdateZone(name, primaryNS, adminEmail string, refresh, retry, expire, minimumTTL int) (*Zone, error) {
name = strings.ToLower(strings.TrimSuffix(name, "."))
zone, err := d.GetZone(name)
if err != nil {
return nil, err
}
serial := nextSerial(zone.Serial)
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
_, err = d.Exec(`UPDATE zones SET primary_ns = ?, admin_email = ?, refresh = ?, retry = ?, expire = ?, minimum_ttl = ?, serial = ?, updated_at = ? WHERE id = ?`,
primaryNS, adminEmail, refresh, retry, expire, minimumTTL, serial, now, zone.ID)
if err != nil {
return nil, fmt.Errorf("update zone: %w", err)
}
return d.GetZoneByID(zone.ID)
}
// DeleteZone deletes a zone and all its records.
func (d *DB) DeleteZone(name string) error {
name = strings.ToLower(strings.TrimSuffix(name, "."))
res, err := d.Exec(`DELETE FROM zones WHERE name = ?`, name)
if err != nil {
return fmt.Errorf("delete zone: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete zone: rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// BumpSerial increments the serial for a zone within a transaction.
func (d *DB) BumpSerial(tx *sql.Tx, zoneID int64) error {
var current int64
if err := tx.QueryRow(`SELECT serial FROM zones WHERE id = ?`, zoneID).Scan(&current); err != nil {
return fmt.Errorf("read serial: %w", err)
}
serial := nextSerial(current)
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
_, err := tx.Exec(`UPDATE zones SET serial = ?, updated_at = ? WHERE id = ?`, serial, now, zoneID)
return err
}
// ZoneNames returns all zone names for the DNS handler.
func (d *DB) ZoneNames() ([]string, error) {
rows, err := d.Query(`SELECT name FROM zones ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
return names, rows.Err()
}
// ApplySOADefaults fills in zero-valued SOA parameters with sensible defaults:
// refresh=3600, retry=600, expire=86400, minTTL=300.
func ApplySOADefaults(refresh, retry, expire, minTTL int) (int, int, int, int) {
if refresh == 0 {
refresh = 3600
}
if retry == 0 {
retry = 600
}
if expire == 0 {
expire = 86400
}
if minTTL == 0 {
minTTL = 300
}
return refresh, retry, expire, minTTL
}
// nextSerial computes the next SOA serial in YYYYMMDDNN format.
func nextSerial(current int64) int64 {
today := time.Now().UTC()
datePrefix, _ := strconv.ParseInt(today.Format("20060102"), 10, 64)
datePrefix *= 100 // YYYYMMDD00
if current >= datePrefix {
return current + 1
}
return datePrefix + 1
}

168
internal/db/zones_test.go Normal file
View File

@@ -0,0 +1,168 @@
package db
import (
"path/filepath"
"testing"
)
func openTestDB(t *testing.T) *DB {
t.Helper()
dir := t.TempDir()
database, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func TestCreateZone(t *testing.T) {
db := openTestDB(t)
zone, err := db.CreateZone("example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
if zone.Name != "example.com" {
t.Fatalf("got name %q, want %q", zone.Name, "example.com")
}
if zone.Serial == 0 {
t.Fatal("serial should not be zero")
}
if zone.PrimaryNS != "ns1.example.com." {
t.Fatalf("got primary_ns %q, want %q", zone.PrimaryNS, "ns1.example.com.")
}
}
func TestCreateZoneDuplicate(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateZone("example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
_, err = db.CreateZone("example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err == nil {
t.Fatal("expected error for duplicate zone")
}
}
func TestCreateZoneNormalization(t *testing.T) {
db := openTestDB(t)
zone, err := db.CreateZone("Example.COM.", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
if zone.Name != "example.com" {
t.Fatalf("got name %q, want %q", zone.Name, "example.com")
}
}
func TestListZones(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateZone("b.example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone b: %v", err)
}
_, err = db.CreateZone("a.example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone a: %v", err)
}
zones, err := db.ListZones()
if err != nil {
t.Fatalf("list zones: %v", err)
}
// 2 seed zones + 2 created = 4 total. Verify ours are present and ordered.
if len(zones) != 4 {
t.Fatalf("got %d zones, want 4", len(zones))
}
if zones[0].Name != "a.example.com" {
t.Fatalf("zones should be ordered by name, got %q first", zones[0].Name)
}
}
func TestGetZone(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateZone("example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
zone, err := db.GetZone("example.com")
if err != nil {
t.Fatalf("get zone: %v", err)
}
if zone.Name != "example.com" {
t.Fatalf("got name %q, want %q", zone.Name, "example.com")
}
_, err = db.GetZone("nonexistent.com")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestUpdateZone(t *testing.T) {
db := openTestDB(t)
original, err := db.CreateZone("example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
updated, err := db.UpdateZone("example.com", "ns2.example.com.", "newadmin.example.com.", 7200, 1200, 172800, 600)
if err != nil {
t.Fatalf("update zone: %v", err)
}
if updated.PrimaryNS != "ns2.example.com." {
t.Fatalf("got primary_ns %q, want %q", updated.PrimaryNS, "ns2.example.com.")
}
if updated.Serial <= original.Serial {
t.Fatalf("serial should have incremented: %d <= %d", updated.Serial, original.Serial)
}
}
func TestDeleteZone(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateZone("example.com", "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
if err := db.DeleteZone("example.com"); err != nil {
t.Fatalf("delete zone: %v", err)
}
_, err = db.GetZone("example.com")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
if err := db.DeleteZone("nonexistent.com"); err != ErrNotFound {
t.Fatalf("expected ErrNotFound for nonexistent zone, got %v", err)
}
}
func TestNextSerial(t *testing.T) {
// A zero serial should produce a date-based serial.
s1 := nextSerial(0)
if s1 < 2026032600 {
t.Fatalf("serial %d seems too low", s1)
}
// Incrementing should increase.
s2 := nextSerial(s1)
if s2 != s1+1 {
t.Fatalf("expected %d, got %d", s1+1, s2)
}
}

67
internal/dns/cache.go Normal file
View File

@@ -0,0 +1,67 @@
package dns
import (
"sync"
"time"
"github.com/miekg/dns"
)
type cacheKey struct {
Name string
Qtype uint16
Class uint16
}
type cacheEntry struct {
msg *dns.Msg
expiresAt time.Time
}
// Cache is a thread-safe in-memory DNS response cache with TTL-based expiry.
type Cache struct {
mu sync.RWMutex
entries map[cacheKey]*cacheEntry
}
// NewCache creates an empty DNS cache.
func NewCache() *Cache {
return &Cache{
entries: make(map[cacheKey]*cacheEntry),
}
}
// Get returns a cached response if it exists and has not expired.
func (c *Cache) Get(name string, qtype, class uint16) *dns.Msg {
c.mu.RLock()
defer c.mu.RUnlock()
key := cacheKey{Name: name, Qtype: qtype, Class: class}
entry, ok := c.entries[key]
if !ok || time.Now().After(entry.expiresAt) {
return nil
}
return entry.msg
}
// Set stores a DNS response in the cache with the given TTL.
func (c *Cache) Set(name string, qtype, class uint16, msg *dns.Msg, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
key := cacheKey{Name: name, Qtype: qtype, Class: class}
c.entries[key] = &cacheEntry{
msg: msg.Copy(),
expiresAt: time.Now().Add(ttl),
}
// Lazy eviction: clean up expired entries if cache is growing.
if len(c.entries) > 1000 {
now := time.Now()
for k, v := range c.entries {
if now.After(v.expiresAt) {
delete(c.entries, k)
}
}
}
}

View File

@@ -0,0 +1,81 @@
package dns
import (
"testing"
"time"
"github.com/miekg/dns"
)
func TestCacheSetGet(t *testing.T) {
c := NewCache()
msg := new(dns.Msg)
msg.SetQuestion("example.com.", dns.TypeA)
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: []byte{1, 2, 3, 4},
})
c.Set("example.com.", dns.TypeA, dns.ClassINET, msg, 5*time.Second)
cached := c.Get("example.com.", dns.TypeA, dns.ClassINET)
if cached == nil {
t.Fatal("expected cached response")
}
if len(cached.Answer) != 1 {
t.Fatalf("got %d answers, want 1", len(cached.Answer))
}
}
func TestCacheMiss(t *testing.T) {
c := NewCache()
cached := c.Get("example.com.", dns.TypeA, dns.ClassINET)
if cached != nil {
t.Fatal("expected nil for cache miss")
}
}
func TestCacheExpiry(t *testing.T) {
c := NewCache()
msg := new(dns.Msg)
msg.SetQuestion("example.com.", dns.TypeA)
c.Set("example.com.", dns.TypeA, dns.ClassINET, msg, 1*time.Millisecond)
time.Sleep(2 * time.Millisecond)
cached := c.Get("example.com.", dns.TypeA, dns.ClassINET)
if cached != nil {
t.Fatal("expected nil for expired entry")
}
}
func TestCacheDifferentTypes(t *testing.T) {
c := NewCache()
msgA := new(dns.Msg)
msgA.SetQuestion("example.com.", dns.TypeA)
c.Set("example.com.", dns.TypeA, dns.ClassINET, msgA, 5*time.Second)
msgAAAA := new(dns.Msg)
msgAAAA.SetQuestion("example.com.", dns.TypeAAAA)
c.Set("example.com.", dns.TypeAAAA, dns.ClassINET, msgAAAA, 5*time.Second)
cachedA := c.Get("example.com.", dns.TypeA, dns.ClassINET)
if cachedA == nil {
t.Fatal("expected cached A response")
}
cachedAAAA := c.Get("example.com.", dns.TypeAAAA, dns.ClassINET)
if cachedAAAA == nil {
t.Fatal("expected cached AAAA response")
}
// Different type should not match.
cachedMX := c.Get("example.com.", dns.TypeMX, dns.ClassINET)
if cachedMX != nil {
t.Fatal("expected nil for uncached type")
}
}

87
internal/dns/forwarder.go Normal file
View File

@@ -0,0 +1,87 @@
package dns
import (
"fmt"
"time"
"github.com/miekg/dns"
)
// Forwarder handles forwarding DNS queries to upstream resolvers.
type Forwarder struct {
upstreams []string
client *dns.Client
cache *Cache
}
// NewForwarder creates a Forwarder with the given upstream addresses.
func NewForwarder(upstreams []string) *Forwarder {
return &Forwarder{
upstreams: upstreams,
client: &dns.Client{
Timeout: 2 * time.Second,
},
cache: NewCache(),
}
}
// Forward sends a query to upstream resolvers and returns the response.
// Responses are cached by (qname, qtype, qclass) with TTL-based expiry.
func (f *Forwarder) Forward(r *dns.Msg) (*dns.Msg, error) {
if len(r.Question) == 0 {
return nil, fmt.Errorf("empty question")
}
q := r.Question[0]
// Check cache.
if cached := f.cache.Get(q.Name, q.Qtype, q.Qclass); cached != nil {
return cached.Copy(), nil
}
// Try each upstream in order.
var lastErr error
for _, upstream := range f.upstreams {
resp, _, err := f.client.Exchange(r, upstream)
if err != nil {
lastErr = err
continue
}
// Don't cache SERVFAIL or REFUSED.
if resp.Rcode != dns.RcodeServerFailure && resp.Rcode != dns.RcodeRefused {
ttl := minTTL(resp)
if ttl > 300 {
ttl = 300
}
if ttl > 0 {
f.cache.Set(q.Name, q.Qtype, q.Qclass, resp, time.Duration(ttl)*time.Second)
}
}
return resp, nil
}
return nil, fmt.Errorf("all upstreams failed: %w", lastErr)
}
// minTTL returns the minimum TTL from all resource records in a response.
func minTTL(msg *dns.Msg) uint32 {
var min uint32
first := true
for _, sections := range [][]dns.RR{msg.Answer, msg.Ns, msg.Extra} {
for _, rr := range sections {
ttl := rr.Header().Ttl
if first || ttl < min {
min = ttl
first = false
}
}
}
if first {
return 60 // No records; default to 60s.
}
return min
}

280
internal/dns/server.go Normal file
View File

@@ -0,0 +1,280 @@
// Package dns implements the authoritative DNS server for MCNS.
// It serves records from SQLite for authoritative zones and forwards
// all other queries to configured upstream resolvers.
package dns
import (
"log/slog"
"net"
"strings"
"github.com/miekg/dns"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// Server is the MCNS DNS server. It listens on both UDP and TCP.
type Server struct {
db *db.DB
forwarder *Forwarder
logger *slog.Logger
udp *dns.Server
tcp *dns.Server
}
// New creates a DNS server that serves records from the database and
// forwards non-authoritative queries to the given upstreams.
func New(database *db.DB, upstreams []string, logger *slog.Logger) *Server {
s := &Server{
db: database,
forwarder: NewForwarder(upstreams),
logger: logger,
}
mux := dns.NewServeMux()
mux.HandleFunc(".", s.handleQuery)
s.udp = &dns.Server{Handler: mux, Net: "udp"}
s.tcp = &dns.Server{Handler: mux, Net: "tcp"}
return s
}
// ListenAndServe starts the DNS server on the given address for both
// UDP and TCP. It blocks until Shutdown is called.
func (s *Server) ListenAndServe(addr string) error {
s.udp.Addr = addr
s.tcp.Addr = addr
errCh := make(chan error, 2)
go func() {
s.logger.Info("dns server listening", "addr", addr, "proto", "udp")
errCh <- s.udp.ListenAndServe()
}()
go func() {
s.logger.Info("dns server listening", "addr", addr, "proto", "tcp")
errCh <- s.tcp.ListenAndServe()
}()
return <-errCh
}
// Shutdown gracefully stops the DNS server.
func (s *Server) Shutdown() {
_ = s.udp.Shutdown()
_ = s.tcp.Shutdown()
}
// handleQuery is the main DNS query handler. It checks if the query
// falls within an authoritative zone and either serves from the database
// or forwards to upstream.
func (s *Server) handleQuery(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
s.writeResponse(w, r, dns.RcodeFormatError, nil, nil)
return
}
q := r.Question[0]
qname := strings.ToLower(q.Name)
// Find the authoritative zone for this query.
zone := s.findZone(qname)
if zone == nil {
// Not authoritative — forward to upstream.
s.forwardQuery(w, r)
return
}
s.handleAuthoritativeQuery(w, r, zone, qname, q.Qtype)
}
// findZone returns the best matching zone for the query name, or nil.
func (s *Server) findZone(qname string) *db.Zone {
// Walk up the domain labels to find the longest matching zone.
name := strings.TrimSuffix(qname, ".")
parts := strings.Split(name, ".")
for i := range parts {
candidate := strings.Join(parts[i:], ".")
zone, err := s.db.GetZone(candidate)
if err == nil {
return zone
}
}
return nil
}
// handleAuthoritativeQuery serves a query from the database.
func (s *Server) handleAuthoritativeQuery(w dns.ResponseWriter, r *dns.Msg, zone *db.Zone, qname string, qtype uint16) {
// Extract the record name relative to the zone.
zoneFQDN := zone.Name + "."
var relName string
if qname == zoneFQDN {
relName = "@"
} else {
relName = strings.TrimSuffix(qname, "."+zoneFQDN)
}
// SOA queries always return the zone apex SOA regardless of query name.
if qtype == dns.TypeSOA {
soa := s.buildSOA(zone)
s.writeResponse(w, r, dns.RcodeSuccess, []dns.RR{soa}, nil)
return
}
// Handle NS queries at the zone apex.
if qtype == dns.TypeNS && relName == "@" {
ns := &dns.NS{
Hdr: dns.RR_Header{Name: zoneFQDN, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: uint32(zone.MinimumTTL)},
Ns: zone.PrimaryNS,
}
s.writeResponse(w, r, dns.RcodeSuccess, []dns.RR{ns}, nil)
return
}
// Look up the requested record type.
var answers []dns.RR
var lookupType string
switch qtype {
case dns.TypeA:
lookupType = "A"
case dns.TypeAAAA:
lookupType = "AAAA"
case dns.TypeCNAME:
lookupType = "CNAME"
default:
// For unsupported types, check if the name exists at all.
// If it does, return empty answer. If not, NXDOMAIN.
exists, _ := s.nameExists(zone.Name, relName)
if exists {
s.writeResponse(w, r, dns.RcodeSuccess, nil, []dns.RR{s.buildSOA(zone)})
} else {
s.writeResponse(w, r, dns.RcodeNameError, nil, []dns.RR{s.buildSOA(zone)})
}
return
}
records, err := s.db.LookupRecords(zone.Name, relName, lookupType)
if err != nil {
s.logger.Error("dns lookup failed", "zone", zone.Name, "name", relName, "type", lookupType, "error", err)
s.writeResponse(w, r, dns.RcodeServerFailure, nil, nil)
return
}
// If no direct records, check for CNAME.
if len(records) == 0 && (qtype == dns.TypeA || qtype == dns.TypeAAAA) {
cnameRecords, err := s.db.LookupCNAME(zone.Name, relName)
if err == nil && len(cnameRecords) > 0 {
for _, rec := range cnameRecords {
answers = append(answers, &dns.CNAME{
Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: uint32(rec.TTL)},
Target: rec.Value,
})
}
s.writeResponse(w, r, dns.RcodeSuccess, answers, nil)
return
}
}
if len(records) == 0 {
// Name might still exist with other record types.
exists, _ := s.nameExists(zone.Name, relName)
if exists {
// NODATA: name exists but no records of requested type.
s.writeResponse(w, r, dns.RcodeSuccess, nil, []dns.RR{s.buildSOA(zone)})
} else {
// NXDOMAIN: name does not exist.
s.writeResponse(w, r, dns.RcodeNameError, nil, []dns.RR{s.buildSOA(zone)})
}
return
}
for _, rec := range records {
rr := s.recordToRR(qname, rec)
if rr != nil {
answers = append(answers, rr)
}
}
s.writeResponse(w, r, dns.RcodeSuccess, answers, nil)
}
// nameExists checks if any records exist for a name in a zone.
func (s *Server) nameExists(zoneName, name string) (bool, error) {
records, err := s.db.ListRecords(zoneName, name, "")
if err != nil {
return false, err
}
return len(records) > 0, nil
}
// recordToRR converts a database Record to a dns.RR.
func (s *Server) recordToRR(qname string, rec db.Record) dns.RR {
hdr := dns.RR_Header{Name: qname, Class: dns.ClassINET, Ttl: uint32(rec.TTL)}
switch rec.Type {
case "A":
hdr.Rrtype = dns.TypeA
return &dns.A{Hdr: hdr, A: parseIP(rec.Value)}
case "AAAA":
hdr.Rrtype = dns.TypeAAAA
return &dns.AAAA{Hdr: hdr, AAAA: parseIP(rec.Value)}
case "CNAME":
hdr.Rrtype = dns.TypeCNAME
return &dns.CNAME{Hdr: hdr, Target: rec.Value}
}
return nil
}
// buildSOA constructs a SOA record for the given zone.
func (s *Server) buildSOA(zone *db.Zone) *dns.SOA {
return &dns.SOA{
Hdr: dns.RR_Header{Name: zone.Name + ".", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: uint32(zone.MinimumTTL)},
Ns: zone.PrimaryNS,
Mbox: zone.AdminEmail,
Serial: uint32(zone.Serial),
Refresh: uint32(zone.Refresh),
Retry: uint32(zone.Retry),
Expire: uint32(zone.Expire),
Minttl: uint32(zone.MinimumTTL),
}
}
// writeResponse constructs and writes a DNS response.
func (s *Server) writeResponse(w dns.ResponseWriter, r *dns.Msg, rcode int, answer []dns.RR, ns []dns.RR) {
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative = true
m.Rcode = rcode
m.Answer = answer
m.Ns = ns
if err := w.WriteMsg(m); err != nil {
s.logger.Error("dns write failed", "error", err)
}
}
// forwardQuery forwards a DNS query to upstream resolvers.
func (s *Server) forwardQuery(w dns.ResponseWriter, r *dns.Msg) {
resp, err := s.forwarder.Forward(r)
if err != nil {
s.logger.Debug("dns forward failed", "error", err)
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
_ = w.WriteMsg(m)
return
}
resp.Id = r.Id
if err := w.WriteMsg(resp); err != nil {
s.logger.Error("dns write failed", "error", err)
}
}
// parseIP parses an IP address string into a net.IP.
func parseIP(s string) net.IP {
return net.ParseIP(s)
}

142
internal/dns/server_test.go Normal file
View File

@@ -0,0 +1,142 @@
package dns
import (
"path/filepath"
"testing"
"github.com/miekg/dns"
"git.wntrmute.dev/mc/mcns/internal/db"
"log/slog"
)
func openTestDB(t *testing.T) *db.DB {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func setupTestServer(t *testing.T) (*Server, *db.DB) {
t.Helper()
database := openTestDB(t)
logger := slog.Default()
_, err := database.CreateZone("test.example.com", "ns.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
_, err = database.CreateRecord("test.example.com", "metacrypt", "A", "192.168.88.181", 300)
if err != nil {
t.Fatalf("create A record: %v", err)
}
_, err = database.CreateRecord("test.example.com", "metacrypt", "A", "100.95.252.120", 300)
if err != nil {
t.Fatalf("create A record 2: %v", err)
}
_, err = database.CreateRecord("test.example.com", "mcr", "AAAA", "2001:db8::1", 300)
if err != nil {
t.Fatalf("create AAAA record: %v", err)
}
_, err = database.CreateRecord("test.example.com", "alias", "CNAME", "metacrypt.test.example.com.", 300)
if err != nil {
t.Fatalf("create CNAME record: %v", err)
}
srv := New(database, []string{"1.1.1.1:53"}, logger)
return srv, database
}
func TestFindZone(t *testing.T) {
srv, _ := setupTestServer(t)
zone := srv.findZone("metacrypt.test.example.com.")
if zone == nil {
t.Fatal("expected to find zone")
}
if zone.Name != "test.example.com" {
t.Fatalf("got zone %q, want %q", zone.Name, "test.example.com")
}
zone = srv.findZone("nonexistent.com.")
if zone != nil {
t.Fatal("expected nil for nonexistent zone")
}
}
func TestBuildSOA(t *testing.T) {
srv, database := setupTestServer(t)
zone, err := database.GetZone("test.example.com")
if err != nil {
t.Fatalf("get zone: %v", err)
}
soa := srv.buildSOA(zone)
if soa.Ns != "ns.example.com." {
t.Fatalf("got ns %q, want %q", soa.Ns, "ns.example.com.")
}
if soa.Hdr.Name != "test.example.com." {
t.Fatalf("got name %q, want %q", soa.Hdr.Name, "test.example.com.")
}
}
func TestRecordToRR_A(t *testing.T) {
srv, _ := setupTestServer(t)
rec := db.Record{Name: "metacrypt", Type: "A", Value: "192.168.88.181", TTL: 300}
rr := srv.recordToRR("metacrypt.test.example.com.", rec)
if rr == nil {
t.Fatal("expected non-nil RR")
}
a, ok := rr.(*dns.A)
if !ok {
t.Fatalf("expected *dns.A, got %T", rr)
}
if a.A.String() != "192.168.88.181" {
t.Fatalf("got IP %q, want %q", a.A.String(), "192.168.88.181")
}
}
func TestRecordToRR_AAAA(t *testing.T) {
srv, _ := setupTestServer(t)
rec := db.Record{Name: "mcr", Type: "AAAA", Value: "2001:db8::1", TTL: 300}
rr := srv.recordToRR("mcr.test.example.com.", rec)
if rr == nil {
t.Fatal("expected non-nil RR")
}
aaaa, ok := rr.(*dns.AAAA)
if !ok {
t.Fatalf("expected *dns.AAAA, got %T", rr)
}
if aaaa.AAAA.String() != "2001:db8::1" {
t.Fatalf("got IP %q, want %q", aaaa.AAAA.String(), "2001:db8::1")
}
}
func TestRecordToRR_CNAME(t *testing.T) {
srv, _ := setupTestServer(t)
rec := db.Record{Name: "alias", Type: "CNAME", Value: "metacrypt.test.example.com.", TTL: 300}
rr := srv.recordToRR("alias.test.example.com.", rec)
if rr == nil {
t.Fatal("expected non-nil RR")
}
cname, ok := rr.(*dns.CNAME)
if !ok {
t.Fatalf("expected *dns.CNAME, got %T", rr)
}
if cname.Target != "metacrypt.test.example.com." {
t.Fatalf("got target %q, want %q", cname.Target, "metacrypt.test.example.com.")
}
}

View File

@@ -0,0 +1,20 @@
package grpcserver
import (
"context"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type adminService struct {
pb.UnimplementedAdminServiceServer
db *db.DB
}
func (s *adminService) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) {
if err := s.db.Ping(); err != nil {
return &pb.HealthResponse{Status: "unhealthy"}, nil
}
return &pb.HealthResponse{Status: "ok"}, nil
}

View File

@@ -0,0 +1,38 @@
package grpcserver
import (
"context"
"errors"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
)
type authService struct {
pb.UnimplementedAuthServiceServer
auth *mcdslauth.Authenticator
}
func (s *authService) Login(_ context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
token, _, err := s.auth.Login(req.Username, req.Password, req.TotpCode)
if err != nil {
if errors.Is(err, mcdslauth.ErrInvalidCredentials) {
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
if errors.Is(err, mcdslauth.ErrForbidden) {
return nil, status.Error(codes.PermissionDenied, "access denied by login policy")
}
return nil, status.Error(codes.Unavailable, "authentication service unavailable")
}
return &pb.LoginResponse{Token: token}, nil
}
func (s *authService) Logout(_ context.Context, req *pb.LogoutRequest) (*pb.LogoutResponse, error) {
if err := s.auth.Logout(req.Token); err != nil {
return nil, status.Error(codes.Internal, "logout failed")
}
return &pb.LogoutResponse{}, nil
}

View File

@@ -0,0 +1,815 @@
package grpcserver
import (
"context"
"encoding/json"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// mockMCIAS starts a fake MCIAS HTTP server for token validation.
// Recognized tokens:
// - "admin-token" -> valid, username=admin-uuid, roles=[admin]
// - "user-token" -> valid, username=user-uuid, roles=[user]
// - anything else -> invalid
func mockMCIAS(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
switch req.Token {
case "admin-token":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": "admin-uuid",
"account_type": "human",
"roles": []string{"admin"},
})
case "user-token":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": "user-uuid",
"account_type": "human",
"roles": []string{"user"},
})
default:
_ = json.NewEncoder(w).Encode(map[string]interface{}{"valid": false})
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// testAuthenticator creates an mcdsl/auth.Authenticator that talks to the given mock MCIAS.
func testAuthenticator(t *testing.T, serverURL string) *mcdslauth.Authenticator {
t.Helper()
a, err := mcdslauth.New(mcdslauth.Config{ServerURL: serverURL}, slog.Default())
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return a
}
// openTestDB creates a temporary test database with migrations applied.
func openTestDB(t *testing.T) *db.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
d, err := db.Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = d.Close() })
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return d
}
// startTestServer creates a gRPC server with auth interceptors and returns
// a connected client. Passing empty cert/key strings skips TLS.
func startTestServer(t *testing.T, deps Deps) *grpc.ClientConn {
t.Helper()
srv, err := New("", "", deps, slog.Default())
if err != nil {
t.Fatalf("New: %v", err)
}
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
go func() {
_ = srv.Serve(lis)
}()
t.Cleanup(func() { srv.GracefulStop() })
//nolint:gosec // insecure credentials for testing only
cc, err := grpc.NewClient(
lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("Dial: %v", err)
}
t.Cleanup(func() { _ = cc.Close() })
return cc
}
// withAuth adds a bearer token to the outgoing context metadata.
func withAuth(ctx context.Context, token string) context.Context {
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
}
// seedZone creates a test zone and returns it.
func seedZone(t *testing.T, database *db.DB, name string) *db.Zone {
t.Helper()
zone, err := database.CreateZone(name, "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("seed zone %q: %v", name, err)
}
return zone
}
// seedRecord creates a test A record and returns it.
func seedRecord(t *testing.T, database *db.DB, zoneName, name, value string) *db.Record {
t.Helper()
rec, err := database.CreateRecord(zoneName, name, "A", value, 300)
if err != nil {
t.Fatalf("seed record %s.%s: %v", name, zoneName, err)
}
return rec
}
// ---------------------------------------------------------------------------
// Admin tests
// ---------------------------------------------------------------------------
func TestHealthBypassesAuth(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
client := pb.NewAdminServiceClient(cc)
// No auth token -- should still succeed because Health is public.
resp, err := client.Health(context.Background(), &pb.HealthRequest{})
if err != nil {
t.Fatalf("Health should not require auth: %v", err)
}
if resp.Status != "ok" {
t.Fatalf("Health status: got %q, want %q", resp.Status, "ok")
}
}
// ---------------------------------------------------------------------------
// Zone tests
// ---------------------------------------------------------------------------
func TestListZones(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "user-token")
client := pb.NewZoneServiceClient(cc)
resp, err := client.ListZones(ctx, &pb.ListZonesRequest{})
if err != nil {
t.Fatalf("ListZones: %v", err)
}
// Seed migration creates 2 zones.
if len(resp.Zones) != 2 {
t.Fatalf("got %d zones, want 2 (seed zones)", len(resp.Zones))
}
}
func TestGetZoneFound(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "user-token")
client := pb.NewZoneServiceClient(cc)
zone, err := client.GetZone(ctx, &pb.GetZoneRequest{Name: "example.com"})
if err != nil {
t.Fatalf("GetZone: %v", err)
}
if zone.Name != "example.com" {
t.Fatalf("got name %q, want %q", zone.Name, "example.com")
}
}
func TestGetZoneNotFound(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "user-token")
client := pb.NewZoneServiceClient(cc)
_, err := client.GetZone(ctx, &pb.GetZoneRequest{Name: "nonexistent.com"})
if err == nil {
t.Fatal("expected error for nonexistent zone")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestCreateZoneSuccess(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewZoneServiceClient(cc)
zone, err := client.CreateZone(ctx, &pb.CreateZoneRequest{
Name: "newzone.com",
PrimaryNs: "ns1.newzone.com.",
AdminEmail: "admin.newzone.com.",
Refresh: 3600,
Retry: 600,
Expire: 86400,
MinimumTtl: 300,
})
if err != nil {
t.Fatalf("CreateZone: %v", err)
}
if zone.Name != "newzone.com" {
t.Fatalf("got name %q, want %q", zone.Name, "newzone.com")
}
if zone.Serial == 0 {
t.Fatal("serial should not be zero")
}
}
func TestCreateZoneDuplicate(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewZoneServiceClient(cc)
_, err := client.CreateZone(ctx, &pb.CreateZoneRequest{
Name: "example.com",
PrimaryNs: "ns1.example.com.",
AdminEmail: "admin.example.com.",
})
if err == nil {
t.Fatal("expected error for duplicate zone")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.AlreadyExists {
t.Fatalf("code: got %v, want AlreadyExists", st.Code())
}
}
func TestUpdateZone(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
original := seedZone(t, database, "example.com")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewZoneServiceClient(cc)
updated, err := client.UpdateZone(ctx, &pb.UpdateZoneRequest{
Name: "example.com",
PrimaryNs: "ns2.example.com.",
AdminEmail: "newadmin.example.com.",
Refresh: 7200,
Retry: 1200,
Expire: 172800,
MinimumTtl: 600,
})
if err != nil {
t.Fatalf("UpdateZone: %v", err)
}
if updated.PrimaryNs != "ns2.example.com." {
t.Fatalf("got primary_ns %q, want %q", updated.PrimaryNs, "ns2.example.com.")
}
if updated.Serial <= original.Serial {
t.Fatalf("serial should have incremented: %d <= %d", updated.Serial, original.Serial)
}
}
func TestDeleteZone(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewZoneServiceClient(cc)
_, err := client.DeleteZone(ctx, &pb.DeleteZoneRequest{Name: "example.com"})
if err != nil {
t.Fatalf("DeleteZone: %v", err)
}
// Verify it is gone.
_, err = client.GetZone(withAuth(context.Background(), "user-token"), &pb.GetZoneRequest{Name: "example.com"})
if err == nil {
t.Fatal("expected NotFound after delete")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestDeleteZoneNotFound(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewZoneServiceClient(cc)
_, err := client.DeleteZone(ctx, &pb.DeleteZoneRequest{Name: "nonexistent.com"})
if err == nil {
t.Fatal("expected error for nonexistent zone")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
// ---------------------------------------------------------------------------
// Record tests
// ---------------------------------------------------------------------------
func TestListRecords(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
seedRecord(t, database, "example.com", "www", "10.0.0.1")
seedRecord(t, database, "example.com", "mail", "10.0.0.2")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "user-token")
client := pb.NewRecordServiceClient(cc)
resp, err := client.ListRecords(ctx, &pb.ListRecordsRequest{Zone: "example.com"})
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(resp.Records) != 2 {
t.Fatalf("got %d records, want 2", len(resp.Records))
}
}
func TestGetRecordFound(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
rec := seedRecord(t, database, "example.com", "www", "10.0.0.1")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "user-token")
client := pb.NewRecordServiceClient(cc)
got, err := client.GetRecord(ctx, &pb.GetRecordRequest{Id: rec.ID})
if err != nil {
t.Fatalf("GetRecord: %v", err)
}
if got.Name != "www" {
t.Fatalf("got name %q, want %q", got.Name, "www")
}
if got.Value != "10.0.0.1" {
t.Fatalf("got value %q, want %q", got.Value, "10.0.0.1")
}
}
func TestGetRecordNotFound(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "user-token")
client := pb.NewRecordServiceClient(cc)
_, err := client.GetRecord(ctx, &pb.GetRecordRequest{Id: 999999})
if err == nil {
t.Fatal("expected error for nonexistent record")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestCreateRecordSuccess(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
rec, err := client.CreateRecord(ctx, &pb.CreateRecordRequest{
Zone: "example.com",
Name: "www",
Type: "A",
Value: "10.0.0.1",
Ttl: 300,
})
if err != nil {
t.Fatalf("CreateRecord: %v", err)
}
if rec.Name != "www" {
t.Fatalf("got name %q, want %q", rec.Name, "www")
}
if rec.Type != "A" {
t.Fatalf("got type %q, want %q", rec.Type, "A")
}
}
func TestCreateRecordInvalidValue(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
_, err := client.CreateRecord(ctx, &pb.CreateRecordRequest{
Zone: "example.com",
Name: "www",
Type: "A",
Value: "not-an-ip",
Ttl: 300,
})
if err == nil {
t.Fatal("expected error for invalid A record value")
}
st, _ := status.FromError(err)
if st.Code() != codes.InvalidArgument {
t.Fatalf("code: got %v, want InvalidArgument", st.Code())
}
}
func TestCreateRecordCNAMEConflict(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
seedRecord(t, database, "example.com", "www", "10.0.0.1")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
// Try to create a CNAME for "www" which already has an A record.
_, err := client.CreateRecord(ctx, &pb.CreateRecordRequest{
Zone: "example.com",
Name: "www",
Type: "CNAME",
Value: "other.example.com.",
Ttl: 300,
})
if err == nil {
t.Fatal("expected error for CNAME conflict with existing A record")
}
st, _ := status.FromError(err)
if st.Code() != codes.AlreadyExists {
t.Fatalf("code: got %v, want AlreadyExists", st.Code())
}
}
func TestCreateRecordAConflictWithCNAME(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
// Create a CNAME first.
_, err := database.CreateRecord("example.com", "alias", "CNAME", "target.example.com.", 300)
if err != nil {
t.Fatalf("seed CNAME: %v", err)
}
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
// Try to create an A record for "alias" which already has a CNAME.
_, err = client.CreateRecord(ctx, &pb.CreateRecordRequest{
Zone: "example.com",
Name: "alias",
Type: "A",
Value: "10.0.0.1",
Ttl: 300,
})
if err == nil {
t.Fatal("expected error for A record conflict with existing CNAME")
}
st, _ := status.FromError(err)
if st.Code() != codes.AlreadyExists {
t.Fatalf("code: got %v, want AlreadyExists", st.Code())
}
}
func TestUpdateRecord(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
rec := seedRecord(t, database, "example.com", "www", "10.0.0.1")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
updated, err := client.UpdateRecord(ctx, &pb.UpdateRecordRequest{
Id: rec.ID,
Name: "www",
Type: "A",
Value: "10.0.0.2",
Ttl: 600,
})
if err != nil {
t.Fatalf("UpdateRecord: %v", err)
}
if updated.Value != "10.0.0.2" {
t.Fatalf("got value %q, want %q", updated.Value, "10.0.0.2")
}
if updated.Ttl != 600 {
t.Fatalf("got ttl %d, want 600", updated.Ttl)
}
}
func TestUpdateRecordNotFound(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
_, err := client.UpdateRecord(ctx, &pb.UpdateRecordRequest{
Id: 999999,
Name: "www",
Type: "A",
Value: "10.0.0.1",
Ttl: 300,
})
if err == nil {
t.Fatal("expected error for nonexistent record")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestDeleteRecord(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
seedZone(t, database, "example.com")
rec := seedRecord(t, database, "example.com", "www", "10.0.0.1")
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
_, err := client.DeleteRecord(ctx, &pb.DeleteRecordRequest{Id: rec.ID})
if err != nil {
t.Fatalf("DeleteRecord: %v", err)
}
// Verify it is gone.
_, err = client.GetRecord(withAuth(context.Background(), "user-token"), &pb.GetRecordRequest{Id: rec.ID})
if err == nil {
t.Fatal("expected NotFound after delete")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestDeleteRecordNotFound(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewRecordServiceClient(cc)
_, err := client.DeleteRecord(ctx, &pb.DeleteRecordRequest{Id: 999999})
if err == nil {
t.Fatal("expected error for nonexistent record")
}
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
// ---------------------------------------------------------------------------
// Auth interceptor tests
// ---------------------------------------------------------------------------
func TestAuthRequiredNoToken(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
client := pb.NewZoneServiceClient(cc)
// No auth token on an auth-required method.
_, err := client.ListZones(context.Background(), &pb.ListZonesRequest{})
if err == nil {
t.Fatal("expected error for unauthenticated request")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.Unauthenticated {
t.Fatalf("code: got %v, want Unauthenticated", st.Code())
}
}
func TestAuthRequiredInvalidToken(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "bad-token")
client := pb.NewZoneServiceClient(cc)
_, err := client.ListZones(ctx, &pb.ListZonesRequest{})
if err == nil {
t.Fatal("expected error for invalid token")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.Unauthenticated {
t.Fatalf("code: got %v, want Unauthenticated", st.Code())
}
}
func TestAdminRequiredDeniedForUser(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "user-token")
client := pb.NewZoneServiceClient(cc)
// CreateZone requires admin.
_, err := client.CreateZone(ctx, &pb.CreateZoneRequest{
Name: "forbidden.com",
PrimaryNs: "ns1.forbidden.com.",
AdminEmail: "admin.forbidden.com.",
})
if err == nil {
t.Fatal("expected error for non-admin user")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.PermissionDenied {
t.Fatalf("code: got %v, want PermissionDenied", st.Code())
}
}
func TestAdminRequiredAllowedForAdmin(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
cc := startTestServer(t, Deps{DB: database, Authenticator: auth})
ctx := withAuth(context.Background(), "admin-token")
client := pb.NewZoneServiceClient(cc)
// Admin should be able to create zones.
zone, err := client.CreateZone(ctx, &pb.CreateZoneRequest{
Name: "admin-created.com",
PrimaryNs: "ns1.admin-created.com.",
AdminEmail: "admin.admin-created.com.",
})
if err != nil {
t.Fatalf("CreateZone as admin: %v", err)
}
if zone.Name != "admin-created.com" {
t.Fatalf("got name %q, want %q", zone.Name, "admin-created.com")
}
}
// ---------------------------------------------------------------------------
// Interceptor map completeness test
// ---------------------------------------------------------------------------
func TestMethodMapCompleteness(t *testing.T) {
mm := methodMap()
expectedPublic := []string{
"/mcns.v1.AdminService/Health",
"/mcns.v1.AuthService/Login",
}
for _, method := range expectedPublic {
if !mm.Public[method] {
t.Errorf("method %s should be public but is not in Public", method)
}
}
if len(mm.Public) != len(expectedPublic) {
t.Errorf("Public has %d entries, expected %d", len(mm.Public), len(expectedPublic))
}
expectedAuth := []string{
"/mcns.v1.AuthService/Logout",
"/mcns.v1.ZoneService/ListZones",
"/mcns.v1.ZoneService/GetZone",
"/mcns.v1.RecordService/ListRecords",
"/mcns.v1.RecordService/GetRecord",
}
for _, method := range expectedAuth {
if !mm.AuthRequired[method] {
t.Errorf("method %s should require auth but is not in AuthRequired", method)
}
}
if len(mm.AuthRequired) != len(expectedAuth) {
t.Errorf("AuthRequired has %d entries, expected %d", len(mm.AuthRequired), len(expectedAuth))
}
expectedAdmin := []string{
"/mcns.v1.ZoneService/CreateZone",
"/mcns.v1.ZoneService/UpdateZone",
"/mcns.v1.ZoneService/DeleteZone",
"/mcns.v1.RecordService/CreateRecord",
"/mcns.v1.RecordService/UpdateRecord",
"/mcns.v1.RecordService/DeleteRecord",
}
for _, method := range expectedAdmin {
if !mm.AdminRequired[method] {
t.Errorf("method %s should require admin but is not in AdminRequired", method)
}
}
if len(mm.AdminRequired) != len(expectedAdmin) {
t.Errorf("AdminRequired has %d entries, expected %d", len(mm.AdminRequired), len(expectedAdmin))
}
// Verify no method appears in multiple maps (each RPC in exactly one map).
all := make(map[string]int)
for k := range mm.Public {
all[k]++
}
for k := range mm.AuthRequired {
all[k]++
}
for k := range mm.AdminRequired {
all[k]++
}
for method, count := range all {
if count != 1 {
t.Errorf("method %s appears in %d maps, expected exactly 1", method, count)
}
}
}

View File

@@ -0,0 +1,45 @@
package grpcserver
import (
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
)
// methodMap builds the mcdsl grpcserver.MethodMap for MCNS.
//
// Adding a new RPC without adding it to the correct map is a security
// defect — the mcdsl auth interceptor denies unmapped methods by default.
func methodMap() mcdslgrpc.MethodMap {
return mcdslgrpc.MethodMap{
Public: publicMethods(),
AuthRequired: authRequiredMethods(),
AdminRequired: adminRequiredMethods(),
}
}
func publicMethods() map[string]bool {
return map[string]bool{
"/mcns.v1.AdminService/Health": true,
"/mcns.v1.AuthService/Login": true,
}
}
func authRequiredMethods() map[string]bool {
return map[string]bool{
"/mcns.v1.AuthService/Logout": true,
"/mcns.v1.ZoneService/ListZones": true,
"/mcns.v1.ZoneService/GetZone": true,
"/mcns.v1.RecordService/ListRecords": true,
"/mcns.v1.RecordService/GetRecord": true,
}
}
func adminRequiredMethods() map[string]bool {
return map[string]bool{
"/mcns.v1.ZoneService/CreateZone": true,
"/mcns.v1.ZoneService/UpdateZone": true,
"/mcns.v1.ZoneService/DeleteZone": true,
"/mcns.v1.RecordService/CreateRecord": true,
"/mcns.v1.RecordService/UpdateRecord": true,
"/mcns.v1.RecordService/DeleteRecord": true,
}
}

View File

@@ -0,0 +1,151 @@
package grpcserver
import (
"context"
"errors"
"log/slog"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type recordService struct {
pb.UnimplementedRecordServiceServer
db *db.DB
logger *slog.Logger
}
func (s *recordService) ListRecords(_ context.Context, req *pb.ListRecordsRequest) (*pb.ListRecordsResponse, error) {
if req.Zone == "" {
return nil, status.Error(codes.InvalidArgument, "zone is required")
}
records, err := s.db.ListRecords(req.Zone, req.Name, req.Type)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to list records")
}
resp := &pb.ListRecordsResponse{}
for _, r := range records {
resp.Records = append(resp.Records, s.recordToProto(r))
}
return resp, nil
}
func (s *recordService) GetRecord(_ context.Context, req *pb.GetRecordRequest) (*pb.Record, error) {
if req.Id <= 0 {
return nil, status.Error(codes.InvalidArgument, "id must be positive")
}
record, err := s.db.GetRecord(req.Id)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "record not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to get record")
}
return s.recordToProto(*record), nil
}
func (s *recordService) CreateRecord(_ context.Context, req *pb.CreateRecordRequest) (*pb.Record, error) {
if req.Zone == "" {
return nil, status.Error(codes.InvalidArgument, "zone is required")
}
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
if req.Type == "" {
return nil, status.Error(codes.InvalidArgument, "type is required")
}
if req.Value == "" {
return nil, status.Error(codes.InvalidArgument, "value is required")
}
record, err := s.db.CreateRecord(req.Zone, req.Name, req.Type, req.Value, int(req.Ttl))
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
}
if errors.Is(err, db.ErrConflict) {
return nil, status.Error(codes.AlreadyExists, err.Error())
}
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return s.recordToProto(*record), nil
}
func (s *recordService) UpdateRecord(_ context.Context, req *pb.UpdateRecordRequest) (*pb.Record, error) {
if req.Id <= 0 {
return nil, status.Error(codes.InvalidArgument, "id must be positive")
}
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
if req.Type == "" {
return nil, status.Error(codes.InvalidArgument, "type is required")
}
if req.Value == "" {
return nil, status.Error(codes.InvalidArgument, "value is required")
}
record, err := s.db.UpdateRecord(req.Id, req.Name, req.Type, req.Value, int(req.Ttl))
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "record not found")
}
if errors.Is(err, db.ErrConflict) {
return nil, status.Error(codes.AlreadyExists, err.Error())
}
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return s.recordToProto(*record), nil
}
func (s *recordService) DeleteRecord(_ context.Context, req *pb.DeleteRecordRequest) (*pb.DeleteRecordResponse, error) {
if req.Id <= 0 {
return nil, status.Error(codes.InvalidArgument, "id must be positive")
}
err := s.db.DeleteRecord(req.Id)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "record not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to delete record")
}
return &pb.DeleteRecordResponse{}, nil
}
func (s *recordService) recordToProto(r db.Record) *pb.Record {
return &pb.Record{
Id: r.ID,
Zone: r.ZoneName,
Name: r.Name,
Type: r.Type,
Value: r.Value,
Ttl: int32(r.TTL),
CreatedAt: s.parseRecordTimestamp(r.CreatedAt),
UpdatedAt: s.parseRecordTimestamp(r.UpdatedAt),
}
}
func (s *recordService) parseRecordTimestamp(v string) *timestamppb.Timestamp {
t, err := parseTime(v)
if err != nil {
s.logger.Warn("failed to parse record timestamp", "value", v, "error", err)
return nil
}
return timestamppb.New(t)
}
func parseTime(s string) (time.Time, error) {
return time.Parse("2006-01-02T15:04:05Z", s)
}

View File

@@ -0,0 +1,50 @@
package grpcserver
import (
"log/slog"
"net"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// Deps holds the dependencies injected into the gRPC server.
type Deps struct {
DB *db.DB
Authenticator *mcdslauth.Authenticator
}
// Server wraps a mcdsl grpcserver.Server with MCNS-specific services.
type Server struct {
srv *mcdslgrpc.Server
}
// New creates a configured gRPC server with MCNS services registered.
func New(certFile, keyFile string, deps Deps, logger *slog.Logger) (*Server, error) {
srv, err := mcdslgrpc.New(certFile, keyFile, deps.Authenticator, methodMap(), logger, nil)
if err != nil {
return nil, err
}
s := &Server{srv: srv}
pb.RegisterAdminServiceServer(srv.GRPCServer, &adminService{db: deps.DB})
pb.RegisterAuthServiceServer(srv.GRPCServer, &authService{auth: deps.Authenticator})
pb.RegisterZoneServiceServer(srv.GRPCServer, &zoneService{db: deps.DB, logger: logger})
pb.RegisterRecordServiceServer(srv.GRPCServer, &recordService{db: deps.DB, logger: logger})
return s, nil
}
// Serve starts the gRPC server on the given listener.
func (s *Server) Serve(lis net.Listener) error {
return s.srv.GRPCServer.Serve(lis)
}
// GracefulStop gracefully stops the gRPC server.
func (s *Server) GracefulStop() {
s.srv.Stop()
}

View File

@@ -0,0 +1,134 @@
package grpcserver
import (
"context"
"errors"
"log/slog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type zoneService struct {
pb.UnimplementedZoneServiceServer
db *db.DB
logger *slog.Logger
}
func (s *zoneService) ListZones(_ context.Context, _ *pb.ListZonesRequest) (*pb.ListZonesResponse, error) {
zones, err := s.db.ListZones()
if err != nil {
return nil, status.Error(codes.Internal, "failed to list zones")
}
resp := &pb.ListZonesResponse{}
for _, z := range zones {
resp.Zones = append(resp.Zones, s.zoneToProto(z))
}
return resp, nil
}
func (s *zoneService) GetZone(_ context.Context, req *pb.GetZoneRequest) (*pb.Zone, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
zone, err := s.db.GetZone(req.Name)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to get zone")
}
return s.zoneToProto(*zone), nil
}
func (s *zoneService) CreateZone(_ context.Context, req *pb.CreateZoneRequest) (*pb.Zone, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
if req.PrimaryNs == "" {
return nil, status.Error(codes.InvalidArgument, "primary_ns is required")
}
if req.AdminEmail == "" {
return nil, status.Error(codes.InvalidArgument, "admin_email is required")
}
refresh, retry, expire, minTTL := db.ApplySOADefaults(int(req.Refresh), int(req.Retry), int(req.Expire), int(req.MinimumTtl))
zone, err := s.db.CreateZone(req.Name, req.PrimaryNs, req.AdminEmail, refresh, retry, expire, minTTL)
if errors.Is(err, db.ErrConflict) {
return nil, status.Error(codes.AlreadyExists, err.Error())
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to create zone")
}
return s.zoneToProto(*zone), nil
}
func (s *zoneService) UpdateZone(_ context.Context, req *pb.UpdateZoneRequest) (*pb.Zone, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
if req.PrimaryNs == "" {
return nil, status.Error(codes.InvalidArgument, "primary_ns is required")
}
if req.AdminEmail == "" {
return nil, status.Error(codes.InvalidArgument, "admin_email is required")
}
refresh, retry, expire, minTTL := db.ApplySOADefaults(int(req.Refresh), int(req.Retry), int(req.Expire), int(req.MinimumTtl))
zone, err := s.db.UpdateZone(req.Name, req.PrimaryNs, req.AdminEmail, refresh, retry, expire, minTTL)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to update zone")
}
return s.zoneToProto(*zone), nil
}
func (s *zoneService) DeleteZone(_ context.Context, req *pb.DeleteZoneRequest) (*pb.DeleteZoneResponse, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
err := s.db.DeleteZone(req.Name)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "failed to delete zone")
}
return &pb.DeleteZoneResponse{}, nil
}
func (s *zoneService) zoneToProto(z db.Zone) *pb.Zone {
return &pb.Zone{
Id: z.ID,
Name: z.Name,
PrimaryNs: z.PrimaryNS,
AdminEmail: z.AdminEmail,
Refresh: int32(z.Refresh),
Retry: int32(z.Retry),
Expire: int32(z.Expire),
MinimumTtl: int32(z.MinimumTTL),
Serial: z.Serial,
CreatedAt: s.parseTimestamp(z.CreatedAt),
UpdatedAt: s.parseTimestamp(z.UpdatedAt),
}
}
func (s *zoneService) parseTimestamp(v string) *timestamppb.Timestamp {
t, err := parseTime(v)
if err != nil {
s.logger.Warn("failed to parse zone timestamp", "value", v, "error", err)
return nil
}
return timestamppb.New(t)
}

62
internal/server/auth.go Normal file
View File

@@ -0,0 +1,62 @@
package server
import (
"encoding/json"
"errors"
"net/http"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type loginResponse struct {
Token string `json:"token"`
}
func loginHandler(auth *mcdslauth.Authenticator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
token, _, err := auth.Login(req.Username, req.Password, req.TOTPCode)
if err != nil {
if errors.Is(err, mcdslauth.ErrInvalidCredentials) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if errors.Is(err, mcdslauth.ErrForbidden) {
writeError(w, http.StatusForbidden, "access denied by login policy")
return
}
writeError(w, http.StatusServiceUnavailable, "authentication service unavailable")
return
}
writeJSON(w, http.StatusOK, loginResponse{Token: token})
}
}
func logoutHandler(auth *mcdslauth.Authenticator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
if err := auth.Logout(token); err != nil {
writeError(w, http.StatusInternalServerError, "logout failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,949 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// openTestDB creates a temporary SQLite database with all migrations applied.
func openTestDB(t *testing.T) *db.DB {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
// createTestZone inserts a zone for use by record tests.
func createTestZone(t *testing.T, database *db.DB) *db.Zone {
t.Helper()
zone, err := database.CreateZone("test.example.com", "ns.example.com.", "admin.example.com.", 3600, 600, 86400, 300)
if err != nil {
t.Fatalf("create zone: %v", err)
}
return zone
}
// newChiRequest builds a request with chi URL params injected into the context.
func newChiRequest(method, target string, body string, params map[string]string) *http.Request {
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, target, strings.NewReader(body))
} else {
r = httptest.NewRequest(method, target, nil)
}
r.Header.Set("Content-Type", "application/json")
if len(params) > 0 {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
return r
}
// decodeJSON decodes the response body into v.
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.NewDecoder(rec.Body).Decode(v); err != nil {
t.Fatalf("decode json: %v", err)
}
}
// ---- Zone handler tests ----
func TestListZonesHandler_SeedOnly(t *testing.T) {
database := openTestDB(t)
handler := listZonesHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones", "", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string][]db.Zone
decodeJSON(t, rec, &resp)
zones := resp["zones"]
if len(zones) != 2 {
t.Fatalf("got %d zones, want 2 (seed zones)", len(zones))
}
}
func TestListZonesHandler_Populated(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
handler := listZonesHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones", "", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string][]db.Zone
decodeJSON(t, rec, &resp)
zones := resp["zones"]
// 2 seed + 1 created = 3.
if len(zones) != 3 {
t.Fatalf("got %d zones, want 3", len(zones))
}
}
func TestGetZoneHandler_Found(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
handler := getZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/test.example.com", "", map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var zone db.Zone
decodeJSON(t, rec, &zone)
if zone.Name != "test.example.com" {
t.Fatalf("zone name = %q, want %q", zone.Name, "test.example.com")
}
}
func TestGetZoneHandler_NotFound(t *testing.T) {
database := openTestDB(t)
handler := getZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/nonexistent.com", "", map[string]string{"zone": "nonexistent.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestCreateZoneHandler_Success(t *testing.T) {
database := openTestDB(t)
body := `{"name":"new.example.com","primary_ns":"ns1.example.com.","admin_email":"admin.example.com."}`
handler := createZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones", body, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
}
var zone db.Zone
decodeJSON(t, rec, &zone)
if zone.Name != "new.example.com" {
t.Fatalf("zone name = %q, want %q", zone.Name, "new.example.com")
}
if zone.PrimaryNS != "ns1.example.com." {
t.Fatalf("primary_ns = %q, want %q", zone.PrimaryNS, "ns1.example.com.")
}
// SOA defaults should be applied.
if zone.Refresh != 3600 {
t.Fatalf("refresh = %d, want 3600", zone.Refresh)
}
}
func TestCreateZoneHandler_MissingFields(t *testing.T) {
tests := []struct {
name string
body string
}{
{"missing name", `{"primary_ns":"ns1.example.com.","admin_email":"admin.example.com."}`},
{"missing primary_ns", `{"name":"new.example.com","admin_email":"admin.example.com."}`},
{"missing admin_email", `{"name":"new.example.com","primary_ns":"ns1.example.com."}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
database := openTestDB(t)
handler := createZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones", tt.body, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
})
}
}
func TestCreateZoneHandler_Duplicate(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
body := `{"name":"test.example.com","primary_ns":"ns1.example.com.","admin_email":"admin.example.com."}`
handler := createZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones", body, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
}
}
func TestCreateZoneHandler_InvalidJSON(t *testing.T) {
database := openTestDB(t)
handler := createZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones", "not json", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestUpdateZoneHandler_Success(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
body := `{"primary_ns":"ns2.example.com.","admin_email":"newadmin.example.com.","refresh":7200}`
handler := updateZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPut, "/v1/zones/test.example.com", body, map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var zone db.Zone
decodeJSON(t, rec, &zone)
if zone.PrimaryNS != "ns2.example.com." {
t.Fatalf("primary_ns = %q, want %q", zone.PrimaryNS, "ns2.example.com.")
}
if zone.Refresh != 7200 {
t.Fatalf("refresh = %d, want 7200", zone.Refresh)
}
}
func TestUpdateZoneHandler_NotFound(t *testing.T) {
database := openTestDB(t)
body := `{"primary_ns":"ns2.example.com.","admin_email":"admin.example.com."}`
handler := updateZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPut, "/v1/zones/nonexistent.com", body, map[string]string{"zone": "nonexistent.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestUpdateZoneHandler_MissingFields(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
body := `{"admin_email":"admin.example.com."}`
handler := updateZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPut, "/v1/zones/test.example.com", body, map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestDeleteZoneHandler_Success(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
handler := deleteZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodDelete, "/v1/zones/test.example.com", "", map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
// Verify the zone is gone.
_, err := database.GetZone("test.example.com")
if err != db.ErrNotFound {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
}
func TestDeleteZoneHandler_NotFound(t *testing.T) {
database := openTestDB(t)
handler := deleteZoneHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodDelete, "/v1/zones/nonexistent.com", "", map[string]string{"zone": "nonexistent.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
// ---- Record handler tests ----
func TestListRecordsHandler_WithZone(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
_, err := database.CreateRecord("test.example.com", "www", "A", "10.0.0.1", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
_, err = database.CreateRecord("test.example.com", "mail", "A", "10.0.0.2", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
handler := listRecordsHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/test.example.com/records", "", map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string][]db.Record
decodeJSON(t, rec, &resp)
records := resp["records"]
if len(records) != 2 {
t.Fatalf("got %d records, want 2", len(records))
}
}
func TestListRecordsHandler_ZoneNotFound(t *testing.T) {
database := openTestDB(t)
handler := listRecordsHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/nonexistent.com/records", "", map[string]string{"zone": "nonexistent.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestListRecordsHandler_EmptyZone(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
handler := listRecordsHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/test.example.com/records", "", map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string][]db.Record
decodeJSON(t, rec, &resp)
records := resp["records"]
if len(records) != 0 {
t.Fatalf("got %d records, want 0", len(records))
}
}
func TestListRecordsHandler_WithFilters(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
_, err := database.CreateRecord("test.example.com", "www", "A", "10.0.0.1", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
_, err = database.CreateRecord("test.example.com", "www", "A", "10.0.0.2", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
_, err = database.CreateRecord("test.example.com", "mail", "A", "10.0.0.3", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
handler := listRecordsHandler(database)
// Filter by name.
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/test.example.com/records?name=www", "", map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string][]db.Record
decodeJSON(t, rec, &resp)
if len(resp["records"]) != 2 {
t.Fatalf("got %d records for name=www, want 2", len(resp["records"]))
}
}
func TestGetRecordHandler_Found(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
created, err := database.CreateRecord("test.example.com", "www", "A", "10.0.0.1", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
handler := getRecordHandler(database)
rec := httptest.NewRecorder()
idStr := fmt.Sprintf("%d", created.ID)
req := newChiRequest(http.MethodGet, "/v1/zones/test.example.com/records/"+idStr, "", map[string]string{
"zone": "test.example.com",
"id": idStr,
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var record db.Record
decodeJSON(t, rec, &record)
if record.Name != "www" {
t.Fatalf("record name = %q, want %q", record.Name, "www")
}
if record.Value != "10.0.0.1" {
t.Fatalf("record value = %q, want %q", record.Value, "10.0.0.1")
}
}
func TestGetRecordHandler_NotFound(t *testing.T) {
database := openTestDB(t)
handler := getRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/test.example.com/records/99999", "", map[string]string{
"zone": "test.example.com",
"id": "99999",
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestGetRecordHandler_InvalidID(t *testing.T) {
database := openTestDB(t)
handler := getRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodGet, "/v1/zones/test.example.com/records/abc", "", map[string]string{
"zone": "test.example.com",
"id": "abc",
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestCreateRecordHandler_Success(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
body := `{"name":"www","type":"A","value":"10.0.0.1","ttl":600}`
handler := createRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones/test.example.com/records", body, map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
}
var record db.Record
decodeJSON(t, rec, &record)
if record.Name != "www" {
t.Fatalf("record name = %q, want %q", record.Name, "www")
}
if record.Type != "A" {
t.Fatalf("record type = %q, want %q", record.Type, "A")
}
if record.TTL != 600 {
t.Fatalf("ttl = %d, want 600", record.TTL)
}
}
func TestCreateRecordHandler_MissingFields(t *testing.T) {
tests := []struct {
name string
body string
}{
{"missing name", `{"type":"A","value":"10.0.0.1"}`},
{"missing type", `{"name":"www","value":"10.0.0.1"}`},
{"missing value", `{"name":"www","type":"A"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
handler := createRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones/test.example.com/records", tt.body, map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
})
}
}
func TestCreateRecordHandler_InvalidIP(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
body := `{"name":"www","type":"A","value":"not-an-ip"}`
handler := createRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones/test.example.com/records", body, map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestCreateRecordHandler_CNAMEConflict(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
// Create an A record first.
_, err := database.CreateRecord("test.example.com", "www", "A", "10.0.0.1", 300)
if err != nil {
t.Fatalf("create A record: %v", err)
}
// Try to create a CNAME for the same name via handler.
body := `{"name":"www","type":"CNAME","value":"other.example.com."}`
handler := createRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones/test.example.com/records", body, map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
}
}
func TestCreateRecordHandler_ZoneNotFound(t *testing.T) {
database := openTestDB(t)
body := `{"name":"www","type":"A","value":"10.0.0.1"}`
handler := createRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones/nonexistent.com/records", body, map[string]string{"zone": "nonexistent.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestCreateRecordHandler_InvalidJSON(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
handler := createRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPost, "/v1/zones/test.example.com/records", "not json", map[string]string{"zone": "test.example.com"})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestUpdateRecordHandler_Success(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
created, err := database.CreateRecord("test.example.com", "www", "A", "10.0.0.1", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
idStr := fmt.Sprintf("%d", created.ID)
body := `{"name":"www","type":"A","value":"10.0.0.2","ttl":600}`
handler := updateRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPut, "/v1/zones/test.example.com/records/"+idStr, body, map[string]string{
"zone": "test.example.com",
"id": idStr,
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var record db.Record
decodeJSON(t, rec, &record)
if record.Value != "10.0.0.2" {
t.Fatalf("value = %q, want %q", record.Value, "10.0.0.2")
}
if record.TTL != 600 {
t.Fatalf("ttl = %d, want 600", record.TTL)
}
}
func TestUpdateRecordHandler_NotFound(t *testing.T) {
database := openTestDB(t)
body := `{"name":"www","type":"A","value":"10.0.0.1"}`
handler := updateRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPut, "/v1/zones/test.example.com/records/99999", body, map[string]string{
"zone": "test.example.com",
"id": "99999",
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestUpdateRecordHandler_InvalidID(t *testing.T) {
database := openTestDB(t)
body := `{"name":"www","type":"A","value":"10.0.0.1"}`
handler := updateRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPut, "/v1/zones/test.example.com/records/abc", body, map[string]string{
"zone": "test.example.com",
"id": "abc",
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestUpdateRecordHandler_MissingFields(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
created, err := database.CreateRecord("test.example.com", "www", "A", "10.0.0.1", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
idStr := fmt.Sprintf("%d", created.ID)
// Missing name.
body := `{"type":"A","value":"10.0.0.1"}`
handler := updateRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodPut, "/v1/zones/test.example.com/records/"+idStr, body, map[string]string{
"zone": "test.example.com",
"id": idStr,
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestDeleteRecordHandler_Success(t *testing.T) {
database := openTestDB(t)
createTestZone(t, database)
created, err := database.CreateRecord("test.example.com", "www", "A", "10.0.0.1", 300)
if err != nil {
t.Fatalf("create record: %v", err)
}
idStr := fmt.Sprintf("%d", created.ID)
handler := deleteRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodDelete, "/v1/zones/test.example.com/records/"+idStr, "", map[string]string{
"zone": "test.example.com",
"id": idStr,
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
// Verify record is gone.
_, err = database.GetRecord(created.ID)
if err != db.ErrNotFound {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
}
func TestDeleteRecordHandler_NotFound(t *testing.T) {
database := openTestDB(t)
handler := deleteRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodDelete, "/v1/zones/test.example.com/records/99999", "", map[string]string{
"zone": "test.example.com",
"id": "99999",
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestDeleteRecordHandler_InvalidID(t *testing.T) {
database := openTestDB(t)
handler := deleteRecordHandler(database)
rec := httptest.NewRecorder()
req := newChiRequest(http.MethodDelete, "/v1/zones/test.example.com/records/abc", "", map[string]string{
"zone": "test.example.com",
"id": "abc",
})
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
// ---- Middleware tests ----
func TestRequireAdmin_WithAdminContext(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := requireAdmin(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// Inject admin TokenInfo into context.
info := &mcdslauth.TokenInfo{
Username: "admin-user",
IsAdmin: true,
Roles: []string{"admin"},
}
ctx := context.WithValue(req.Context(), tokenInfoKey, info)
req = req.WithContext(ctx)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !called {
t.Fatal("inner handler was not called")
}
}
func TestRequireAdmin_WithNonAdminContext(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := requireAdmin(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// Inject non-admin TokenInfo into context.
info := &mcdslauth.TokenInfo{
Username: "regular-user",
IsAdmin: false,
Roles: []string{"viewer"},
}
ctx := context.WithValue(req.Context(), tokenInfoKey, info)
req = req.WithContext(ctx)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
if called {
t.Fatal("inner handler should not have been called")
}
}
func TestRequireAdmin_NoTokenInfo(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
handler := requireAdmin(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
if called {
t.Fatal("inner handler should not have been called")
}
}
func TestExtractBearerToken(t *testing.T) {
tests := []struct {
name string
header string
want string
}{
{"valid bearer", "Bearer abc123", "abc123"},
{"empty header", "", ""},
{"no prefix", "abc123", ""},
{"basic auth", "Basic abc123", ""},
{"bearer with spaces", "Bearer token-with-space ", "token-with-space"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if tt.header != "" {
r.Header.Set("Authorization", tt.header)
}
got := extractBearerToken(r)
if got != tt.want {
t.Fatalf("extractBearerToken(%q) = %q, want %q", tt.header, got, tt.want)
}
})
}
}
func TestTokenInfoFromContext(t *testing.T) {
// No token info in context.
ctx := context.Background()
if info := tokenInfoFromContext(ctx); info != nil {
t.Fatal("expected nil, got token info")
}
// With token info.
expected := &mcdslauth.TokenInfo{Username: "testuser", IsAdmin: true}
ctx = context.WithValue(ctx, tokenInfoKey, expected)
got := tokenInfoFromContext(ctx)
if got == nil {
t.Fatal("expected token info, got nil")
}
if got.Username != expected.Username {
t.Fatalf("username = %q, want %q", got.Username, expected.Username)
}
if !got.IsAdmin {
t.Fatal("expected IsAdmin to be true")
}
}
// ---- writeJSON / writeError tests ----
func TestWriteJSON(t *testing.T) {
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusOK, map[string]string{"key": "value"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("content-type = %q, want %q", ct, "application/json")
}
var resp map[string]string
decodeJSON(t, rec, &resp)
if resp["key"] != "value" {
t.Fatalf("got key=%q, want %q", resp["key"], "value")
}
}
func TestWriteError(t *testing.T) {
rec := httptest.NewRecorder()
writeError(rec, http.StatusBadRequest, "bad input")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
var resp map[string]string
decodeJSON(t, rec, &resp)
if resp["error"] != "bad input" {
t.Fatalf("got error=%q, want %q", resp["error"], "bad input")
}
}

View File

@@ -0,0 +1,96 @@
package server
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
)
type contextKey string
const tokenInfoKey contextKey = "tokenInfo"
// requireAuth returns middleware that validates Bearer tokens via MCIAS.
func requireAuth(auth *mcdslauth.Authenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
info, err := auth.ValidateToken(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), tokenInfoKey, info)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// requireAdmin is middleware that checks the caller has the admin role.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if info == nil || !info.IsAdmin {
writeError(w, http.StatusForbidden, "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
// tokenInfoFromContext extracts the TokenInfo from the request context.
func tokenInfoFromContext(ctx context.Context) *mcdslauth.TokenInfo {
info, _ := ctx.Value(tokenInfoKey).(*mcdslauth.TokenInfo)
return info
}
// extractBearerToken extracts a bearer token from the Authorization header.
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
const prefix = "Bearer "
if !strings.HasPrefix(h, prefix) {
return ""
}
return strings.TrimSpace(h[len(prefix):])
}
// loggingMiddleware logs HTTP requests.
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
logger.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"duration", time.Since(start),
"remote", r.RemoteAddr,
)
})
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}

174
internal/server/records.go Normal file
View File

@@ -0,0 +1,174 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type createRecordRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
func listRecordsHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
zoneName := chi.URLParam(r, "zone")
nameFilter := r.URL.Query().Get("name")
typeFilter := r.URL.Query().Get("type")
records, err := database.ListRecords(zoneName, nameFilter, typeFilter)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "zone not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list records")
return
}
if records == nil {
records = []db.Record{}
}
writeJSON(w, http.StatusOK, map[string]any{"records": records})
}
}
func getRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid record ID")
return
}
record, err := database.GetRecord(id)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "record not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get record")
return
}
writeJSON(w, http.StatusOK, record)
}
}
func createRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
zoneName := chi.URLParam(r, "zone")
var req createRecordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type == "" {
writeError(w, http.StatusBadRequest, "type is required")
return
}
if req.Value == "" {
writeError(w, http.StatusBadRequest, "value is required")
return
}
record, err := database.CreateRecord(zoneName, req.Name, req.Type, req.Value, req.TTL)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "zone not found")
return
}
if errors.Is(err, db.ErrConflict) {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, record)
}
}
func updateRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid record ID")
return
}
var req createRecordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type == "" {
writeError(w, http.StatusBadRequest, "type is required")
return
}
if req.Value == "" {
writeError(w, http.StatusBadRequest, "value is required")
return
}
record, err := database.UpdateRecord(id, req.Name, req.Type, req.Value, req.TTL)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "record not found")
return
}
if errors.Is(err, db.ErrConflict) {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, record)
}
}
func deleteRecordHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid record ID")
return
}
err = database.DeleteRecord(id)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "record not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete record")
return
}
w.WriteHeader(http.StatusNoContent)
}
}

71
internal/server/routes.go Normal file
View File

@@ -0,0 +1,71 @@
package server
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/health"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// Deps holds dependencies injected into the REST handlers.
type Deps struct {
DB *db.DB
Auth *mcdslauth.Authenticator
Logger *slog.Logger
}
// NewRouter builds the chi router with all MCNS REST endpoints.
func NewRouter(deps Deps) *chi.Mux {
r := chi.NewRouter()
r.Use(loggingMiddleware(deps.Logger))
// Public endpoints.
r.Post("/v1/auth/login", loginHandler(deps.Auth))
r.Get("/v1/health", health.Handler(deps.DB.DB))
// Authenticated endpoints.
r.Group(func(r chi.Router) {
r.Use(requireAuth(deps.Auth))
r.Post("/v1/auth/logout", logoutHandler(deps.Auth))
// Zone endpoints — reads for all authenticated users, writes for admin.
r.Get("/v1/zones", listZonesHandler(deps.DB))
r.Get("/v1/zones/{zone}", getZoneHandler(deps.DB))
// Admin-only zone mutations.
r.With(requireAdmin).Post("/v1/zones", createZoneHandler(deps.DB))
r.With(requireAdmin).Put("/v1/zones/{zone}", updateZoneHandler(deps.DB))
r.With(requireAdmin).Delete("/v1/zones/{zone}", deleteZoneHandler(deps.DB))
// Record endpoints — reads for all authenticated users, writes for admin.
r.Get("/v1/zones/{zone}/records", listRecordsHandler(deps.DB))
r.Get("/v1/zones/{zone}/records/{id}", getRecordHandler(deps.DB))
// Admin-only record mutations.
r.With(requireAdmin).Post("/v1/zones/{zone}/records", createRecordHandler(deps.DB))
r.With(requireAdmin).Put("/v1/zones/{zone}/records/{id}", updateRecordHandler(deps.DB))
r.With(requireAdmin).Delete("/v1/zones/{zone}/records/{id}", deleteRecordHandler(deps.DB))
})
return r
}
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a standard error response.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}

141
internal/server/zones.go Normal file
View File

@@ -0,0 +1,141 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type createZoneRequest struct {
Name string `json:"name"`
PrimaryNS string `json:"primary_ns"`
AdminEmail string `json:"admin_email"`
Refresh int `json:"refresh"`
Retry int `json:"retry"`
Expire int `json:"expire"`
MinimumTTL int `json:"minimum_ttl"`
}
func listZonesHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
zones, err := database.ListZones()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list zones")
return
}
if zones == nil {
zones = []db.Zone{}
}
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
}
}
func getZoneHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "zone")
zone, err := database.GetZone(name)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "zone not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get zone")
return
}
writeJSON(w, http.StatusOK, zone)
}
}
func createZoneHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createZoneRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.PrimaryNS == "" {
writeError(w, http.StatusBadRequest, "primary_ns is required")
return
}
if req.AdminEmail == "" {
writeError(w, http.StatusBadRequest, "admin_email is required")
return
}
// Apply defaults for SOA params.
req.Refresh, req.Retry, req.Expire, req.MinimumTTL = db.ApplySOADefaults(req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
zone, err := database.CreateZone(req.Name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
if errors.Is(err, db.ErrConflict) {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create zone")
return
}
writeJSON(w, http.StatusCreated, zone)
}
}
func updateZoneHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "zone")
var req createZoneRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.PrimaryNS == "" {
writeError(w, http.StatusBadRequest, "primary_ns is required")
return
}
if req.AdminEmail == "" {
writeError(w, http.StatusBadRequest, "admin_email is required")
return
}
req.Refresh, req.Retry, req.Expire, req.MinimumTTL = db.ApplySOADefaults(req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
zone, err := database.UpdateZone(name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "zone not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update zone")
return
}
writeJSON(w, http.StatusOK, zone)
}
}
func deleteZoneHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "zone")
err := database.DeleteZone(name)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "zone not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete zone")
return
}
w.WriteHeader(http.StatusNoContent)
}
}

16
proto/mcns/v1/admin.proto Normal file
View File

@@ -0,0 +1,16 @@
syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
// AdminService exposes server health and administrative operations.
service AdminService {
rpc Health(HealthRequest) returns (HealthResponse);
}
message HealthRequest {}
message HealthResponse {
string status = 1;
}

28
proto/mcns/v1/auth.proto Normal file
View File

@@ -0,0 +1,28 @@
syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
// AuthService handles authentication by delegating to MCIAS.
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc Logout(LogoutRequest) returns (LogoutResponse);
}
message LoginRequest {
string username = 1;
string password = 2;
// TOTP code for two-factor authentication, if enabled on the account.
string totp_code = 3;
}
message LoginResponse {
string token = 1;
}
message LogoutRequest {
string token = 1;
}
message LogoutResponse {}

View File

@@ -0,0 +1,68 @@
syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
import "google/protobuf/timestamp.proto";
// RecordService manages DNS records within zones.
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);
}
message Record {
int64 id = 1;
// Zone name this record belongs to (e.g. "example.com.").
string zone = 2;
string name = 3;
// DNS record type (A, AAAA, CNAME, MX, TXT, etc.).
string type = 4;
string value = 5;
int32 ttl = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
message ListRecordsRequest {
string zone = 1;
// Optional filter by record name.
string name = 2;
// Optional filter by record type (A, AAAA, CNAME, etc.).
string type = 3;
}
message ListRecordsResponse {
repeated Record records = 1;
}
message CreateRecordRequest {
// Zone name the record will be created in; must reference an existing zone.
string zone = 1;
string name = 2;
string type = 3;
string value = 4;
int32 ttl = 5;
}
message GetRecordRequest {
int64 id = 1;
}
message UpdateRecordRequest {
int64 id = 1;
string name = 2;
string type = 3;
string value = 4;
int32 ttl = 5;
}
message DeleteRecordRequest {
int64 id = 1;
}
message DeleteRecordResponse {}

66
proto/mcns/v1/zone.proto Normal file
View File

@@ -0,0 +1,66 @@
syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
import "google/protobuf/timestamp.proto";
// ZoneService manages DNS zones and their SOA parameters.
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);
}
message Zone {
int64 id = 1;
string name = 2;
string primary_ns = 3;
string admin_email = 4;
int32 refresh = 5;
int32 retry = 6;
int32 expire = 7;
int32 minimum_ttl = 8;
int64 serial = 9;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
}
message ListZonesRequest {}
message ListZonesResponse {
repeated Zone zones = 1;
}
message CreateZoneRequest {
string name = 1;
string primary_ns = 2;
string admin_email = 3;
int32 refresh = 4;
int32 retry = 5;
int32 expire = 6;
int32 minimum_ttl = 7;
}
message GetZoneRequest {
string name = 1;
}
message UpdateZoneRequest {
string name = 1;
string primary_ns = 2;
string admin_email = 3;
int32 refresh = 4;
int32 retry = 5;
int32 expire = 6;
int32 minimum_ttl = 7;
}
message DeleteZoneRequest {
string name = 1;
}
message DeleteZoneResponse {}

View File

@@ -1,26 +0,0 @@
; Node addresses for Metacircular platform.
; Maps node names to their network addresses.
;
; When MCNS is built, these will be managed via the MCNS API.
; Until then, this file is manually maintained.
$ORIGIN mcp.metacircular.net.
$TTL 300
@ IN SOA ns.mcp.metacircular.net. admin.metacircular.net. (
2026032501 ; serial (YYYYMMDDNN)
3600 ; refresh
600 ; retry
86400 ; expire
300 ; minimum TTL
)
IN NS ns.mcp.metacircular.net.
; --- Nodes ---
rift IN A 192.168.88.181
rift IN A 100.95.252.120
; ns record target — points to rift where CoreDNS runs.
ns IN A 192.168.88.181
ns IN A 100.95.252.120

View File

@@ -1,28 +0,0 @@
; Internal service addresses for Metacircular platform.
; Maps service names to the node where they currently run.
;
; When MCNS is built, MCP will manage these records dynamically.
; Until then, this file is manually maintained.
$ORIGIN svc.mcp.metacircular.net.
$TTL 300
@ IN SOA ns.mcp.metacircular.net. admin.metacircular.net. (
2026032601 ; serial (YYYYMMDDNN)
3600 ; refresh
600 ; retry
86400 ; expire
300 ; minimum TTL
)
IN NS ns.mcp.metacircular.net.
; --- Services on rift ---
metacrypt IN A 192.168.88.181
metacrypt IN A 100.95.252.120
mcr IN A 192.168.88.181
mcr IN A 100.95.252.120
sgard IN A 192.168.88.181
sgard IN A 100.95.252.120
mcp-agent IN A 192.168.88.181
mcp-agent IN A 100.95.252.120