14 Commits

Author SHA1 Message Date
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
9ac944fb39 Document ListRecords filtering, gRPC examples, and expand CLAUDE.md
ARCHITECTURE.md:
- Note optional ?name=/&type= query filters on GET /v1/zones/{zone}/records
- Document ListRecordsRequest name/type fields as optional filters in gRPC service
- Add gRPC usage examples section with grpcurl commands

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

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

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

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

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

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:

42
README.md Normal file
View File

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

264
RUNBOOK.md Normal file
View File

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

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

@@ -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/v1;mcnsv1b\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

@@ -222,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/v1;mcnsv1b\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

@@ -551,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/v1;mcnsv1b\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

@@ -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/v1;mcnsv1b\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

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

@@ -16,10 +16,10 @@ import (
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
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"
)
// mockMCIAS starts a fake MCIAS HTTP server for token validation.

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

@@ -12,8 +12,8 @@ import (
"github.com/go-chi/chi/v5"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
"git.wntrmute.dev/kyle/mcns/internal/db"
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/mc/mcns/internal/db"
)
// openTestDB creates a temporary SQLite database with all migrations applied.

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,7 +2,7 @@ syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/kyle/mcns/gen/mcns/v1;mcnsv1";
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
// AdminService exposes server health and administrative operations.
service AdminService {

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/kyle/mcns/gen/mcns/v1;mcnsv1";
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
// AuthService handles authentication by delegating to MCIAS.
service AuthService {

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/kyle/mcns/gen/mcns/v1;mcnsv1";
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
import "google/protobuf/timestamp.proto";

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
package mcns.v1;
option go_package = "git.wntrmute.dev/kyle/mcns/gen/mcns/v1;mcnsv1";
option go_package = "git.wntrmute.dev/mc/mcns/gen/mcns/v1;mcnsv1";
import "google/protobuf/timestamp.proto";