16 Commits

Author SHA1 Message Date
363c680530 Regenerate proto files for mc/ module path
Raw descriptor bytes in .pb.go files were corrupted by the sed-based
module path rename (string length changed, breaking protobuf binary
encoding). Regenerated with protoc to fix.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:05:54 -07:00
9ac944fb39 Document ListRecords filtering, gRPC examples, and expand CLAUDE.md
ARCHITECTURE.md:
- Note optional ?name=/&type= query filters on GET /v1/zones/{zone}/records
- Document ListRecordsRequest name/type fields as optional filters in gRPC service
- Add gRPC usage examples section with grpcurl commands

CLAUDE.md:
- Add mcdsl shared library section
- Add testing patterns (stdlib only, real SQLite, no mocks)
- Add key invariants: SOA serial YYYYMMDDNN format, CNAME exclusivity at DB layer

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:02:05 -07:00
46 changed files with 2295 additions and 178 deletions

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@ srv/
*.db
*.db-wal
*.db-shm
.idea/
.vscode/
.DS_Store

View File

@@ -206,7 +206,7 @@ The management API (REST + gRPC) uses MCIAS bearer tokens:
| 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 |
| GET | `/v1/zones/{zone}/records` | Bearer | List records in a zone (optional `?name=`/`?type=` filters) |
| POST | `/v1/zones/{zone}/records` | Admin | Create a record |
| GET | `/v1/zones/{zone}/records/{id}` | Bearer | Get a record |
| PUT | `/v1/zones/{zone}/records/{id}` | Admin | Update a record |
@@ -229,6 +229,8 @@ service ZoneService {
}
service RecordService {
// ListRecords returns records in a zone. The name and type fields in
// ListRecordsRequest are optional filters; omit them to return all records.
rpc ListRecords(ListRecordsRequest) returns (ListRecordsResponse);
rpc CreateRecord(CreateRecordRequest) returns (Record);
rpc GetRecord(GetRecordRequest) returns (Record);
@@ -298,6 +300,31 @@ Response 201:
}
```
### gRPC Usage Examples
**List zones with grpcurl:**
```bash
grpcurl -cacert ca.pem \
-H "Authorization: Bearer $TOKEN" \
mcns.svc.mcp.metacircular.net:9443 mcns.v1.ZoneService/ListZones
```
**Create a record with grpcurl:**
```bash
grpcurl -cacert ca.pem \
-H "Authorization: Bearer $TOKEN" \
-d '{"zone":"svc.mcp.metacircular.net","name":"metacrypt","type":"A","value":"192.168.88.181","ttl":300}' \
mcns.svc.mcp.metacircular.net:9443 mcns.v1.RecordService/CreateRecord
```
**List records with name filter:**
```bash
grpcurl -cacert ca.pem \
-H "Authorization: Bearer $TOKEN" \
-d '{"zone":"svc.mcp.metacircular.net","name":"metacrypt"}' \
mcns.svc.mcp.metacircular.net:9443 mcns.v1.RecordService/ListRecords
```
## Configuration
```toml

View File

@@ -56,6 +56,27 @@ deploy/ Docker, systemd, install scripts, examples
- `srv/` — local dev runtime data
- `gen/` — regenerated from proto, not hand-edited
## Shared Library
MCNS uses `mcdsl` (git.wntrmute.dev/mc/mcdsl) for shared platform packages:
auth, db, config, httpserver, grpcserver. These provide MCIAS authentication,
SQLite database helpers, TOML config loading, and TLS-configured HTTP/gRPC
server scaffolding.
## Testing Patterns
- Use stdlib `testing` only. No third-party test frameworks.
- Tests use real SQLite databases in `t.TempDir()`. No mocks for databases.
## Key Invariants
- **SOA serial format**: YYYYMMDDNN, auto-incremented on every record mutation.
If the date prefix matches today, NN is incremented. Otherwise the serial
resets to today with NN=01.
- **CNAME exclusivity**: Enforced at the DB layer within transactions. A name
cannot have both CNAME and A/AAAA records. Attempts to violate this return
`ErrConflict`.
## Critical Rules
1. **REST/gRPC sync**: Every REST endpoint must have a corresponding gRPC

View File

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

View File

@@ -18,8 +18,8 @@ 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 \
protoc --go_out=. --go_opt=module=git.wntrmute.dev/mc/mcns \
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/mc/mcns \
proto/mcns/v1/*.proto
proto-lint:

View File

@@ -230,6 +230,28 @@ Symptoms: MCNS fails to start with "address already in use" on port 53.
`[dns] listen_addr` in `mcns.toml` to a different address.
4. Restart MCNS and verify DNS is responding.
## Deployment with MCP
MCNS runs on rift as a single container managed by MCP. The service
definition lives at `~/.config/mcp/services/mcns.toml` on the operator's
machine. A reference copy is maintained at `deploy/mcns-rift.toml` in
this repository.
The container image is pulled from MCR. The container mounts `/srv/mcns`
and runs as `--user 0:0`. DNS listens on port 53 (UDP+TCP) on both
192.168.88.181 and 100.95.252.120, with the management API on 8443/9443.
Note: the operator's `~/.config/mcp/services/mcns.toml` may still
reference the old CoreDNS image and needs updating to the new MCNS image.
### Key Operations
1. Deploy or update: `mcp deploy mcns`
2. Restart: `mcp restart mcns`
3. Stop: `mcp stop mcns` (WARNING: stops DNS for all internal zones)
4. Check status: `mcp ps` or `mcp status mcns`
5. View logs: `ssh rift 'doas su - mcp -s /bin/sh -c "podman logs mcns"'`
## Escalation
Escalate when:

View File

@@ -16,14 +16,14 @@ import (
"github.com/spf13/cobra"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
mcdsldb "git.wntrmute.dev/mc/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"
"git.wntrmute.dev/mc/mcns/internal/config"
"git.wntrmute.dev/mc/mcns/internal/db"
mcnsdns "git.wntrmute.dev/mc/mcns/internal/dns"
"git.wntrmute.dev/mc/mcns/internal/grpcserver"
"git.wntrmute.dev/mc/mcns/internal/server"
)
var version = "dev"
@@ -138,6 +138,26 @@ func runServer(configPath string) error {
}
}
// shutdownAll tears down all servers. Safe to call even if some
// servers were never started. grpcSrv.Serve takes ownership of
// grpcLis, so we only close grpcLis if we never reached Serve.
grpcServeStarted := false
shutdownAll := func() {
dnsServer.Shutdown()
if grpcSrv != nil {
grpcSrv.GracefulStop()
} else if grpcLis != nil && !grpcServeStarted {
_ = grpcLis.Close()
}
shutdownTimeout := 30 * time.Second
if cfg.Server.ShutdownTimeout.Duration > 0 {
shutdownTimeout = cfg.Server.ShutdownTimeout.Duration
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
}
// Graceful shutdown on SIGINT/SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -151,6 +171,7 @@ func runServer(configPath string) error {
// Start gRPC server.
if grpcSrv != nil {
grpcServeStarted = true
go func() {
logger.Info("gRPC server listening", "addr", grpcLis.Addr())
errCh <- grpcSrv.Serve(grpcLis)
@@ -169,22 +190,11 @@ func runServer(configPath string) error {
select {
case err := <-errCh:
shutdownAll()
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)
}
shutdownAll()
logger.Info("mcns stopped")
return nil
}

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
// protoc v6.32.1
// source: proto/mcns/v1/admin.proto
package v1
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
@@ -110,7 +110,7 @@ const file_proto_mcns_v1_admin_proto_rawDesc = "" +
"\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/v1b\x06proto3"
"\x06Health\x12\x16.mcns.v1.HealthRequest\x1a\x17.mcns.v1.HealthResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_admin_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// - protoc v6.32.1
// source: proto/mcns/v1/admin.proto
package v1
package mcnsv1
import (
context "context"
@@ -25,6 +25,8 @@ const (
// 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)
}
@@ -50,6 +52,8 @@ func (c *adminServiceClient) Health(ctx context.Context, in *HealthRequest, opts
// 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()

View File

@@ -4,7 +4,7 @@
// protoc v6.32.1
// source: proto/mcns/v1/auth.proto
package v1
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
@@ -25,6 +25,7 @@ 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
@@ -221,7 +222,7 @@ const file_proto_mcns_v1_auth_proto_rawDesc = "" +
"\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/v1b\x06proto3"
"\x06Logout\x12\x16.mcns.v1.LogoutRequest\x1a\x17.mcns.v1.LogoutResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_auth_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// - protoc v6.32.1
// source: proto/mcns/v1/auth.proto
package v1
package mcnsv1
import (
context "context"
@@ -26,6 +26,8 @@ const (
// 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)
@@ -62,6 +64,8 @@ func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts
// 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)

View File

@@ -4,7 +4,7 @@
// protoc v6.32.1
// source: proto/mcns/v1/record.proto
package v1
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
@@ -25,8 +25,10 @@ const (
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"`
@@ -125,7 +127,9 @@ func (x *Record) GetUpdatedAt() *timestamppb.Timestamp {
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
@@ -228,6 +232,7 @@ func (x *ListRecordsResponse) GetRecords() []*Record {
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"`
@@ -546,7 +551,7 @@ const file_proto_mcns_v1_record_proto_rawDesc = "" +
"\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/v1b\x06proto3"
"\fDeleteRecord\x12\x1c.mcns.v1.DeleteRecordRequest\x1a\x1d.mcns.v1.DeleteRecordResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_record_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// - protoc v6.32.1
// source: proto/mcns/v1/record.proto
package v1
package mcnsv1
import (
context "context"
@@ -29,6 +29,8 @@ const (
// 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)
@@ -98,6 +100,8 @@ func (c *recordServiceClient) DeleteRecord(ctx context.Context, in *DeleteRecord
// 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)

View File

@@ -4,7 +4,7 @@
// protoc v6.32.1
// source: proto/mcns/v1/zone.proto
package v1
package mcnsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
@@ -595,7 +595,7 @@ const file_proto_mcns_v1_zone_proto_rawDesc = "" +
"\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/v1b\x06proto3"
"DeleteZone\x12\x1a.mcns.v1.DeleteZoneRequest\x1a\x1b.mcns.v1.DeleteZoneResponseB-Z+git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1b\x06proto3"
var (
file_proto_mcns_v1_zone_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// - protoc v6.32.1
// source: proto/mcns/v1/zone.proto
package v1
package mcnsv1
import (
context "context"
@@ -29,6 +29,8 @@ const (
// 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)
@@ -98,6 +100,8 @@ func (c *zoneServiceClient) DeleteZone(ctx context.Context, in *DeleteZoneReques
// 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)

4
go.mod
View File

@@ -1,9 +1,9 @@
module git.wntrmute.dev/kyle/mcns
module git.wntrmute.dev/mc/mcns
go 1.25.7
require (
git.wntrmute.dev/kyle/mcdsl v1.0.0
git.wntrmute.dev/mc/mcdsl v1.2.0
github.com/go-chi/chi/v5 v5.2.5
github.com/miekg/dns v1.1.66
github.com/spf13/cobra v1.10.2

4
go.sum
View File

@@ -1,5 +1,5 @@
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=
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=

View File

@@ -3,7 +3,7 @@ package config
import (
"fmt"
mcdslconfig "git.wntrmute.dev/kyle/mcdsl/config"
mcdslconfig "git.wntrmute.dev/mc/mcdsl/config"
)
// Config is the top-level MCNS configuration.
@@ -45,5 +45,11 @@ func (c *Config) Validate() error {
if c.MCIAS.ServerURL == "" {
return fmt.Errorf("mcias.server_url is required")
}
if c.Server.TLSCert == "" {
return fmt.Errorf("server.tls_cert is required")
}
if c.Server.TLSKey == "" {
return fmt.Errorf("server.tls_key is required")
}
return nil
}

View File

@@ -4,7 +4,7 @@ import (
"database/sql"
"fmt"
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
)
// DB wraps a SQLite database connection.

View File

@@ -1,7 +1,7 @@
package db
import (
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
)
// Migrations is the ordered list of MCNS schema migrations.
@@ -43,28 +43,28 @@ CREATE INDEX IF NOT EXISTS idx_records_zone_name ON records(zone_id, name);`,
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)
INSERT OR IGNORE INTO zones (id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial)
VALUES (1, 'svc.mcp.metacircular.net', 'ns.mcp.metacircular.net.', 'admin.metacircular.net.', 3600, 600, 86400, 300, 2026032601);
-- Zone: mcp.metacircular.net (node addresses)
INSERT INTO zones (id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial)
INSERT OR IGNORE INTO zones (id, name, primary_ns, admin_email, refresh, retry, expire, minimum_ttl, serial)
VALUES (2, 'mcp.metacircular.net', 'ns.mcp.metacircular.net.', 'admin.metacircular.net.', 3600, 600, 86400, 300, 2026032501);
-- svc.mcp.metacircular.net records
INSERT 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);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'metacrypt', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'metacrypt', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcr', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcr', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'sgard', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'sgard', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcp-agent', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (1, 'mcp-agent', 'A', '100.95.252.120', 300);
-- mcp.metacircular.net records
INSERT 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);`,
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'rift', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'rift', 'A', '100.95.252.120', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'ns', 'A', '192.168.88.181', 300);
INSERT OR IGNORE INTO records (zone_id, name, type, value, ttl) VALUES (2, 'ns', 'A', '100.95.252.120', 300);`,
},
}

View File

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

View File

@@ -169,6 +169,24 @@ func (d *DB) ZoneNames() ([]string, error) {
return names, rows.Err()
}
// ApplySOADefaults fills in zero-valued SOA parameters with sensible defaults:
// refresh=3600, retry=600, expire=86400, minTTL=300.
func ApplySOADefaults(refresh, retry, expire, minTTL int) (int, int, int, int) {
if refresh == 0 {
refresh = 3600
}
if retry == 0 {
retry = 600
}
if expire == 0 {
expire = 86400
}
if minTTL == 0 {
minTTL = 300
}
return refresh, retry, expire, minTTL
}
// nextSerial computes the next SOA serial in YYYYMMDDNN format.
func nextSerial(current int64) int64 {
today := time.Now().UTC()

View File

@@ -10,7 +10,7 @@ import (
"github.com/miekg/dns"
"git.wntrmute.dev/kyle/mcns/internal/db"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// Server is the MCNS DNS server. It listens on both UDP and TCP.
@@ -116,8 +116,8 @@ func (s *Server) handleAuthoritativeQuery(w dns.ResponseWriter, r *dns.Msg, zone
relName = strings.TrimSuffix(qname, "."+zoneFQDN)
}
// Handle SOA queries.
if qtype == dns.TypeSOA || relName == "@" && qtype == dns.TypeSOA {
// SOA queries always return the zone apex SOA regardless of query name.
if qtype == dns.TypeSOA {
soa := s.buildSOA(zone)
s.writeResponse(w, r, dns.RcodeSuccess, []dns.RR{soa}, nil)
return

View File

@@ -6,7 +6,7 @@ import (
"github.com/miekg/dns"
"git.wntrmute.dev/kyle/mcns/internal/db"
"git.wntrmute.dev/mc/mcns/internal/db"
"log/slog"
)

View File

@@ -3,8 +3,8 @@ package grpcserver
import (
"context"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/mcns/internal/db"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type adminService struct {

View File

@@ -4,11 +4,11 @@ import (
"context"
"errors"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
)
type authService struct {

View File

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

View File

@@ -1,7 +1,7 @@
package grpcserver
import (
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
)
// methodMap builds the mcdsl grpcserver.MethodMap for MCNS.

View File

@@ -3,22 +3,28 @@ package grpcserver
import (
"context"
"errors"
"log/slog"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/mcns/internal/db"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type recordService struct {
pb.UnimplementedRecordServiceServer
db *db.DB
logger *slog.Logger
}
func (s *recordService) ListRecords(_ context.Context, req *pb.ListRecordsRequest) (*pb.ListRecordsResponse, error) {
if req.Zone == "" {
return nil, status.Error(codes.InvalidArgument, "zone is required")
}
records, err := s.db.ListRecords(req.Zone, req.Name, req.Type)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
@@ -29,12 +35,16 @@ func (s *recordService) ListRecords(_ context.Context, req *pb.ListRecordsReques
resp := &pb.ListRecordsResponse{}
for _, r := range records {
resp.Records = append(resp.Records, recordToProto(r))
resp.Records = append(resp.Records, s.recordToProto(r))
}
return resp, nil
}
func (s *recordService) GetRecord(_ context.Context, req *pb.GetRecordRequest) (*pb.Record, error) {
if req.Id <= 0 {
return nil, status.Error(codes.InvalidArgument, "id must be positive")
}
record, err := s.db.GetRecord(req.Id)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "record not found")
@@ -42,10 +52,23 @@ func (s *recordService) GetRecord(_ context.Context, req *pb.GetRecordRequest) (
if err != nil {
return nil, status.Error(codes.Internal, "failed to get record")
}
return recordToProto(*record), nil
return s.recordToProto(*record), nil
}
func (s *recordService) CreateRecord(_ context.Context, req *pb.CreateRecordRequest) (*pb.Record, error) {
if req.Zone == "" {
return nil, status.Error(codes.InvalidArgument, "zone is required")
}
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
if req.Type == "" {
return nil, status.Error(codes.InvalidArgument, "type is required")
}
if req.Value == "" {
return nil, status.Error(codes.InvalidArgument, "value is required")
}
record, err := s.db.CreateRecord(req.Zone, req.Name, req.Type, req.Value, int(req.Ttl))
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
@@ -56,10 +79,23 @@ func (s *recordService) CreateRecord(_ context.Context, req *pb.CreateRecordRequ
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return recordToProto(*record), nil
return s.recordToProto(*record), nil
}
func (s *recordService) UpdateRecord(_ context.Context, req *pb.UpdateRecordRequest) (*pb.Record, error) {
if req.Id <= 0 {
return nil, status.Error(codes.InvalidArgument, "id must be positive")
}
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
if req.Type == "" {
return nil, status.Error(codes.InvalidArgument, "type is required")
}
if req.Value == "" {
return nil, status.Error(codes.InvalidArgument, "value is required")
}
record, err := s.db.UpdateRecord(req.Id, req.Name, req.Type, req.Value, int(req.Ttl))
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "record not found")
@@ -70,10 +106,14 @@ func (s *recordService) UpdateRecord(_ context.Context, req *pb.UpdateRecordRequ
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return recordToProto(*record), nil
return s.recordToProto(*record), nil
}
func (s *recordService) DeleteRecord(_ context.Context, req *pb.DeleteRecordRequest) (*pb.DeleteRecordResponse, error) {
if req.Id <= 0 {
return nil, status.Error(codes.InvalidArgument, "id must be positive")
}
err := s.db.DeleteRecord(req.Id)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "record not found")
@@ -84,7 +124,7 @@ func (s *recordService) DeleteRecord(_ context.Context, req *pb.DeleteRecordRequ
return &pb.DeleteRecordResponse{}, nil
}
func recordToProto(r db.Record) *pb.Record {
func (s *recordService) recordToProto(r db.Record) *pb.Record {
return &pb.Record{
Id: r.ID,
Zone: r.ZoneName,
@@ -92,14 +132,15 @@ func recordToProto(r db.Record) *pb.Record {
Type: r.Type,
Value: r.Value,
Ttl: int32(r.TTL),
CreatedAt: parseRecordTimestamp(r.CreatedAt),
UpdatedAt: parseRecordTimestamp(r.UpdatedAt),
CreatedAt: s.parseRecordTimestamp(r.CreatedAt),
UpdatedAt: s.parseRecordTimestamp(r.UpdatedAt),
}
}
func parseRecordTimestamp(s string) *timestamppb.Timestamp {
t, err := parseTime(s)
func (s *recordService) parseRecordTimestamp(v string) *timestamppb.Timestamp {
t, err := parseTime(v)
if err != nil {
s.logger.Warn("failed to parse record timestamp", "value", v, "error", err)
return nil
}
return timestamppb.New(t)

View File

@@ -4,11 +4,11 @@ import (
"log/slog"
"net"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/mcns/internal/db"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// Deps holds the dependencies injected into the gRPC server.
@@ -24,7 +24,7 @@ type Server struct {
// 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)
srv, err := mcdslgrpc.New(certFile, keyFile, deps.Authenticator, methodMap(), logger, nil)
if err != nil {
return nil, err
}
@@ -33,8 +33,8 @@ func New(certFile, keyFile string, deps Deps, logger *slog.Logger) (*Server, err
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})
pb.RegisterZoneServiceServer(srv.GRPCServer, &zoneService{db: deps.DB, logger: logger})
pb.RegisterRecordServiceServer(srv.GRPCServer, &recordService{db: deps.DB, logger: logger})
return s, nil
}

View File

@@ -3,18 +3,20 @@ package grpcserver
import (
"context"
"errors"
"log/slog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
"git.wntrmute.dev/kyle/mcns/internal/db"
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type zoneService struct {
pb.UnimplementedZoneServiceServer
db *db.DB
logger *slog.Logger
}
func (s *zoneService) ListZones(_ context.Context, _ *pb.ListZonesRequest) (*pb.ListZonesResponse, error) {
@@ -25,12 +27,16 @@ func (s *zoneService) ListZones(_ context.Context, _ *pb.ListZonesRequest) (*pb.
resp := &pb.ListZonesResponse{}
for _, z := range zones {
resp.Zones = append(resp.Zones, zoneToProto(z))
resp.Zones = append(resp.Zones, s.zoneToProto(z))
}
return resp, nil
}
func (s *zoneService) GetZone(_ context.Context, req *pb.GetZoneRequest) (*pb.Zone, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
zone, err := s.db.GetZone(req.Name)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
@@ -38,27 +44,22 @@ func (s *zoneService) GetZone(_ context.Context, req *pb.GetZoneRequest) (*pb.Zo
if err != nil {
return nil, status.Error(codes.Internal, "failed to get zone")
}
return zoneToProto(*zone), nil
return s.zoneToProto(*zone), nil
}
func (s *zoneService) CreateZone(_ context.Context, req *pb.CreateZoneRequest) (*pb.Zone, error) {
refresh := int(req.Refresh)
if refresh == 0 {
refresh = 3600
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
retry := int(req.Retry)
if retry == 0 {
retry = 600
if req.PrimaryNs == "" {
return nil, status.Error(codes.InvalidArgument, "primary_ns is required")
}
expire := int(req.Expire)
if expire == 0 {
expire = 86400
}
minTTL := int(req.MinimumTtl)
if minTTL == 0 {
minTTL = 300
if req.AdminEmail == "" {
return nil, status.Error(codes.InvalidArgument, "admin_email is required")
}
refresh, retry, expire, minTTL := db.ApplySOADefaults(int(req.Refresh), int(req.Retry), int(req.Expire), int(req.MinimumTtl))
zone, err := s.db.CreateZone(req.Name, req.PrimaryNs, req.AdminEmail, refresh, retry, expire, minTTL)
if errors.Is(err, db.ErrConflict) {
return nil, status.Error(codes.AlreadyExists, err.Error())
@@ -66,27 +67,22 @@ func (s *zoneService) CreateZone(_ context.Context, req *pb.CreateZoneRequest) (
if err != nil {
return nil, status.Error(codes.Internal, "failed to create zone")
}
return zoneToProto(*zone), nil
return s.zoneToProto(*zone), nil
}
func (s *zoneService) UpdateZone(_ context.Context, req *pb.UpdateZoneRequest) (*pb.Zone, error) {
refresh := int(req.Refresh)
if refresh == 0 {
refresh = 3600
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
retry := int(req.Retry)
if retry == 0 {
retry = 600
if req.PrimaryNs == "" {
return nil, status.Error(codes.InvalidArgument, "primary_ns is required")
}
expire := int(req.Expire)
if expire == 0 {
expire = 86400
}
minTTL := int(req.MinimumTtl)
if minTTL == 0 {
minTTL = 300
if req.AdminEmail == "" {
return nil, status.Error(codes.InvalidArgument, "admin_email is required")
}
refresh, retry, expire, minTTL := db.ApplySOADefaults(int(req.Refresh), int(req.Retry), int(req.Expire), int(req.MinimumTtl))
zone, err := s.db.UpdateZone(req.Name, req.PrimaryNs, req.AdminEmail, refresh, retry, expire, minTTL)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
@@ -94,10 +90,14 @@ func (s *zoneService) UpdateZone(_ context.Context, req *pb.UpdateZoneRequest) (
if err != nil {
return nil, status.Error(codes.Internal, "failed to update zone")
}
return zoneToProto(*zone), nil
return s.zoneToProto(*zone), nil
}
func (s *zoneService) DeleteZone(_ context.Context, req *pb.DeleteZoneRequest) (*pb.DeleteZoneResponse, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
err := s.db.DeleteZone(req.Name)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "zone not found")
@@ -108,7 +108,7 @@ func (s *zoneService) DeleteZone(_ context.Context, req *pb.DeleteZoneRequest) (
return &pb.DeleteZoneResponse{}, nil
}
func zoneToProto(z db.Zone) *pb.Zone {
func (s *zoneService) zoneToProto(z db.Zone) *pb.Zone {
return &pb.Zone{
Id: z.ID,
Name: z.Name,
@@ -119,15 +119,15 @@ func zoneToProto(z db.Zone) *pb.Zone {
Expire: int32(z.Expire),
MinimumTtl: int32(z.MinimumTTL),
Serial: z.Serial,
CreatedAt: parseTimestamp(z.CreatedAt),
UpdatedAt: parseTimestamp(z.UpdatedAt),
CreatedAt: s.parseTimestamp(z.CreatedAt),
UpdatedAt: s.parseTimestamp(z.UpdatedAt),
}
}
func parseTimestamp(s string) *timestamppb.Timestamp {
// SQLite stores as "2006-01-02T15:04:05Z".
t, err := parseTime(s)
func (s *zoneService) parseTimestamp(v string) *timestamppb.Timestamp {
t, err := parseTime(v)
if err != nil {
s.logger.Warn("failed to parse zone timestamp", "value", v, "error", err)
return nil
}
return timestamppb.New(t)

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
)
type loginRequest struct {

View File

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

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
)
type contextKey string

View File

@@ -8,7 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/mcns/internal/db"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type createRecordRequest struct {

View File

@@ -7,10 +7,10 @@ import (
"github.com/go-chi/chi/v5"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
"git.wntrmute.dev/kyle/mcdsl/health"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcdsl/health"
"git.wntrmute.dev/kyle/mcns/internal/db"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// Deps holds dependencies injected into the REST handlers.

View File

@@ -7,7 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/mcns/internal/db"
"git.wntrmute.dev/mc/mcns/internal/db"
)
type createZoneRequest struct {
@@ -72,18 +72,7 @@ func createZoneHandler(database *db.DB) http.HandlerFunc {
}
// 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
}
req.Refresh, req.Retry, req.Expire, req.MinimumTTL = db.ApplySOADefaults(req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
zone, err := database.CreateZone(req.Name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
if errors.Is(err, db.ErrConflict) {
@@ -117,18 +106,7 @@ func updateZoneHandler(database *db.DB) http.HandlerFunc {
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
}
req.Refresh, req.Retry, req.Expire, req.MinimumTTL = db.ApplySOADefaults(req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
zone, err := database.UpdateZone(name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
if errors.Is(err, db.ErrNotFound) {

View File

@@ -2,8 +2,9 @@ syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/kyle/mcns/gen/mcns/v1";
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
// AdminService exposes server health and administrative operations.
service AdminService {
rpc Health(HealthRequest) returns (HealthResponse);
}

View File

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

View File

@@ -2,10 +2,11 @@ syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/kyle/mcns/gen/mcns/v1";
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
import "google/protobuf/timestamp.proto";
// RecordService manages DNS records within zones.
service RecordService {
rpc ListRecords(ListRecordsRequest) returns (ListRecordsResponse);
rpc CreateRecord(CreateRecordRequest) returns (Record);
@@ -16,8 +17,10 @@ service RecordService {
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;
@@ -27,7 +30,9 @@ message Record {
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;
}
@@ -36,6 +41,7 @@ message ListRecordsResponse {
}
message CreateRecordRequest {
// Zone name the record will be created in; must reference an existing zone.
string zone = 1;
string name = 2;
string type = 3;

View File

@@ -2,10 +2,11 @@ syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/kyle/mcns/gen/mcns/v1";
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
import "google/protobuf/timestamp.proto";
// ZoneService manages DNS zones and their SOA parameters.
service ZoneService {
rpc ListZones(ListZonesRequest) returns (ListZonesResponse);
rpc CreateZone(CreateZoneRequest) returns (Zone);