4 Commits

Author SHA1 Message Date
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
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
51 changed files with 7510 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"

423
ARCHITECTURE.md Normal file
View File

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

View File

@@ -2,46 +2,70 @@
## Overview ## Overview
MCNS precursor — a CoreDNS instance serving internal DNS zones for the MCNS (Metacircular Networking Service) is an authoritative DNS server for
Metacircular platform until the full MCNS service is built. 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 ## Build Commands
| 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
```bash ```bash
# Start make all # vet → lint → test → build
docker compose -f deploy/docker/docker-compose-rift.yml up -d make build # go build ./...
make test # go test ./...
# Test resolution make vet # go vet ./...
dig @192.168.88.181 metacrypt.svc.mcp.metacircular.net make lint # golangci-lint run ./...
dig @192.168.88.181 rift.mcp.metacircular.net make proto # regenerate gRPC code from .proto files
make proto-lint # buf lint + buf breaking
# After editing zone files, bump the serial and restart make mcns # build mcns binary with version injection
docker compose -f deploy/docker/docker-compose-rift.yml restart 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` ## Architecture
2. Bump the serial number (YYYYMMDDNN format)
3. Restart CoreDNS
## Adding a node Three listeners on one binary:
1. Add an A record to `zones/mcp.metacircular.net.zone` - **DNS server** (`:53`, UDP+TCP) — serves authoritative zones from SQLite,
2. Bump the serial number forwards everything else to upstream resolvers. No authentication.
3. Restart CoreDNS - **REST API** (`:8443`) — management API for zones and records. MCIAS auth.
- **gRPC API** (`:9443`) — same management operations. MCIAS auth.
Records stored in SQLite. Changes visible to DNS immediately on commit.
## Project Structure
```
cmd/mcns/ CLI entry point (server, snapshot, status)
internal/
config/ TOML config with DNS-specific sections
db/ SQLite schema, migrations, zone/record queries
dns/ DNS server, handler, forwarder, cache
server/ REST API routes and handlers
grpcserver/ gRPC server, interceptors, service handlers
proto/mcns/v1/ Protobuf definitions
gen/mcns/v1/ Generated Go code (do not edit)
deploy/ Docker, systemd, install scripts, examples
```
## Ignored Directories
- `srv/` — local dev runtime data
- `gen/` — regenerated from proto, not hand-edited
## Critical Rules
1. **REST/gRPC sync**: Every REST endpoint must have a corresponding gRPC
RPC, updated in the same change.
2. **gRPC interceptor maps**: New RPCs must be added to the correct
interceptor map (Public, AuthRequired, AdminRequired). Forgetting this
is a security defect.
3. **CNAME exclusivity**: A name cannot have both CNAME and A/AAAA records.
Enforced in the database layer within a transaction.
4. **SOA serial**: Auto-incremented on every record mutation. Never manually
set or decremented.
5. **DNS has no auth**: Port 53 serves records to any client. All mutations
go through the authenticated management API.

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
}

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
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 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"]
CMD ["server", "--config", "/srv/mcns/mcns.toml"]

40
Makefile Normal file
View File

@@ -0,0 +1,40 @@
.PHONY: build test vet lint proto proto-lint clean docker all devserver
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
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/kyle/mcns \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/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=$(shell git describe --tags --always --dirty) -t mcns -f Dockerfile .
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

9
buf.yaml Normal file
View File

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

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

@@ -0,0 +1,278 @@
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/kyle/mcdsl/auth"
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
"git.wntrmute.dev/kyle/mcns/internal/config"
"git.wntrmute.dev/kyle/mcns/internal/db"
mcnsdns "git.wntrmute.dev/kyle/mcns/internal/dns"
"git.wntrmute.dev/kyle/mcns/internal/grpcserver"
"git.wntrmute.dev/kyle/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)
}
}
// 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 {
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:
return fmt.Errorf("server error: %w", err)
case <-ctx.Done():
logger.Info("shutting down")
dnsServer.Shutdown()
if grpcSrv != nil {
grpcSrv.GracefulStop()
}
shutdownTimeout := 30 * time.Second
if cfg.Server.ShutdownTimeout.Duration > 0 {
shutdownTimeout = cfg.Server.ShutdownTimeout.Duration
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
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. # MCNS on rift — authoritative DNS + management API.
#
# Serves the svc.mcp.metacircular.net and mcp.metacircular.net zones.
# Forwards everything else to 1.1.1.1 and 8.8.8.8.
# #
# Usage: # Usage:
# docker compose -f deploy/docker/docker-compose-rift.yml up -d # 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: services:
coredns: mcns:
image: coredns/coredns:1.12.1 image: mcr.svc.mcp.metacircular.net:8443/mcns:latest
container_name: mcns-coredns container_name: mcns
restart: unless-stopped restart: unless-stopped
command: -conf /etc/coredns/Corefile command: ["server", "--config", "/srv/mcns/mcns.toml"]
ports: ports:
- "192.168.88.181:53:53/udp" - "192.168.88.181:53:53/udp"
- "192.168.88.181:53:53/tcp" - "192.168.88.181:53:53/tcp"
- "100.95.252.120:53:53/udp" - "100.95.252.120:53:53/udp"
- "100.95.252.120:53:53/tcp" - "100.95.252.120:53:53/tcp"
volumes: volumes:
- ../../Corefile:/etc/coredns/Corefile:ro - /srv/mcns:/srv/mcns
- ../../zones:/etc/coredns/zones:ro

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"

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/kyle/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/kyle/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/kyle/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/kyle/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/kyle/mcns
go 1.25.7
require (
git.wntrmute.dev/kyle/mcdsl v1.0.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/kyle/mcdsl v1.0.0 h1:YB7dx4gdNYKKcVySpL6UkwHqdCJ9Nl1yS0+eHk0hNtk=
git.wntrmute.dev/kyle/mcdsl v1.0.0/go.mod h1:wo0tGfUAxci3XnOe4/rFmR0RjUElKdYUazc+Np986sg=
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=

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

@@ -0,0 +1,49 @@
package config
import (
"fmt"
mcdslconfig "git.wntrmute.dev/kyle/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")
}
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/kyle/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/kyle/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 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 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 INTO records (zone_id, name, type, value, ttl) VALUES (1, 'metacrypt', 'A', '192.168.88.181', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (1, 'metacrypt', 'A', '100.95.252.120', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcr', 'A', '192.168.88.181', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcr', 'A', '100.95.252.120', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (1, 'sgard', 'A', '192.168.88.181', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (1, 'sgard', 'A', '100.95.252.120', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcp-agent', 'A', '192.168.88.181', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcp-agent', 'A', '100.95.252.120', 300);
-- mcp.metacircular.net records
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (2, 'rift', 'A', '192.168.88.181', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (2, 'rift', 'A', '100.95.252.120', 300);
INSERT INTO records (zone_id, name, type, value, ttl) VALUES (2, 'ns', 'A', '192.168.88.181', 300);
INSERT 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)
}

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))
}
}

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

@@ -0,0 +1,182 @@
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()
}
// 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/kyle/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)
}
// Handle SOA queries.
if qtype == dns.TypeSOA || relName == "@" && 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/kyle/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/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/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/kyle/mcdsl/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/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,45 @@
package grpcserver
import (
mcdslgrpc "git.wntrmute.dev/kyle/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,110 @@
package grpcserver
import (
"context"
"errors"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/mcns/internal/db"
)
type recordService struct {
pb.UnimplementedRecordServiceServer
db *db.DB
}
func (s *recordService) ListRecords(_ context.Context, req *pb.ListRecordsRequest) (*pb.ListRecordsResponse, error) {
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, recordToProto(r))
}
return resp, nil
}
func (s *recordService) GetRecord(_ context.Context, req *pb.GetRecordRequest) (*pb.Record, error) {
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 recordToProto(*record), nil
}
func (s *recordService) CreateRecord(_ context.Context, req *pb.CreateRecordRequest) (*pb.Record, error) {
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 recordToProto(*record), nil
}
func (s *recordService) UpdateRecord(_ context.Context, req *pb.UpdateRecordRequest) (*pb.Record, error) {
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 recordToProto(*record), nil
}
func (s *recordService) DeleteRecord(_ context.Context, req *pb.DeleteRecordRequest) (*pb.DeleteRecordResponse, error) {
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 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: parseRecordTimestamp(r.CreatedAt),
UpdatedAt: parseRecordTimestamp(r.UpdatedAt),
}
}
func parseRecordTimestamp(s string) *timestamppb.Timestamp {
t, err := parseTime(s)
if err != nil {
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/kyle/mcdsl/auth"
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/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)
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})
pb.RegisterRecordServiceServer(srv.GRPCServer, &recordService{db: deps.DB})
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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/mcns/internal/db"
)
type zoneService struct {
pb.UnimplementedZoneServiceServer
db *db.DB
}
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, zoneToProto(z))
}
return resp, nil
}
func (s *zoneService) GetZone(_ context.Context, req *pb.GetZoneRequest) (*pb.Zone, error) {
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 zoneToProto(*zone), nil
}
func (s *zoneService) CreateZone(_ context.Context, req *pb.CreateZoneRequest) (*pb.Zone, error) {
refresh := int(req.Refresh)
if refresh == 0 {
refresh = 3600
}
retry := int(req.Retry)
if retry == 0 {
retry = 600
}
expire := int(req.Expire)
if expire == 0 {
expire = 86400
}
minTTL := int(req.MinimumTtl)
if minTTL == 0 {
minTTL = 300
}
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 zoneToProto(*zone), nil
}
func (s *zoneService) UpdateZone(_ context.Context, req *pb.UpdateZoneRequest) (*pb.Zone, error) {
refresh := int(req.Refresh)
if refresh == 0 {
refresh = 3600
}
retry := int(req.Retry)
if retry == 0 {
retry = 600
}
expire := int(req.Expire)
if expire == 0 {
expire = 86400
}
minTTL := int(req.MinimumTtl)
if minTTL == 0 {
minTTL = 300
}
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 zoneToProto(*zone), nil
}
func (s *zoneService) DeleteZone(_ context.Context, req *pb.DeleteZoneRequest) (*pb.DeleteZoneResponse, error) {
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 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: parseTimestamp(z.CreatedAt),
UpdatedAt: parseTimestamp(z.UpdatedAt),
}
}
func parseTimestamp(s string) *timestamppb.Timestamp {
// SQLite stores as "2006-01-02T15:04:05Z".
t, err := parseTime(s)
if err != nil {
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/kyle/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/kyle/mcdsl/auth"
"git.wntrmute.dev/kyle/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/kyle/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/kyle/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/kyle/mcdsl/auth"
"git.wntrmute.dev/kyle/mcdsl/health"
"git.wntrmute.dev/kyle/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})
}

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

@@ -0,0 +1,163 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/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.
if req.Refresh == 0 {
req.Refresh = 3600
}
if req.Retry == 0 {
req.Retry = 600
}
if req.Expire == 0 {
req.Expire = 86400
}
if req.MinimumTTL == 0 {
req.MinimumTTL = 300
}
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
}
if req.Refresh == 0 {
req.Refresh = 3600
}
if req.Retry == 0 {
req.Retry = 600
}
if req.Expire == 0 {
req.Expire = 86400
}
if req.MinimumTTL == 0 {
req.MinimumTTL = 300
}
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/kyle/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/kyle/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/kyle/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/kyle/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