Compare commits
16 Commits
unit11-git
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 363c680530 | |||
| 115802cbe2 | |||
| 42fff97e17 | |||
| 0fe2e90d9a | |||
| 94aa3a9002 | |||
| 1455ce6e0f | |||
| 089989ba77 | |||
| f8f3a9868a | |||
| edcf99e8d1 | |||
| 58f69afd90 | |||
| 58e756ac06 | |||
| 82b7d295ef | |||
| 9ac944fb39 | |||
| 96b5a0fa1b | |||
| 7b11f527f2 | |||
| efd307f7fd |
@@ -206,7 +206,7 @@ The management API (REST + gRPC) uses MCIAS bearer tokens:
|
|||||||
| GET | `/v1/zones/{zone}` | Bearer | Get zone details |
|
| GET | `/v1/zones/{zone}` | Bearer | Get zone details |
|
||||||
| PUT | `/v1/zones/{zone}` | Admin | Update zone SOA parameters |
|
| PUT | `/v1/zones/{zone}` | Admin | Update zone SOA parameters |
|
||||||
| DELETE | `/v1/zones/{zone}` | Admin | Delete zone and all its records |
|
| 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 |
|
| POST | `/v1/zones/{zone}/records` | Admin | Create a record |
|
||||||
| GET | `/v1/zones/{zone}/records/{id}` | Bearer | Get a record |
|
| GET | `/v1/zones/{zone}/records/{id}` | Bearer | Get a record |
|
||||||
| PUT | `/v1/zones/{zone}/records/{id}` | Admin | Update a record |
|
| PUT | `/v1/zones/{zone}/records/{id}` | Admin | Update a record |
|
||||||
@@ -229,6 +229,8 @@ service ZoneService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
service RecordService {
|
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 ListRecords(ListRecordsRequest) returns (ListRecordsResponse);
|
||||||
rpc CreateRecord(CreateRecordRequest) returns (Record);
|
rpc CreateRecord(CreateRecordRequest) returns (Record);
|
||||||
rpc GetRecord(GetRecordRequest) 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
|
## Configuration
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|||||||
21
CLAUDE.md
21
CLAUDE.md
@@ -56,6 +56,27 @@ deploy/ Docker, systemd, install scripts, examples
|
|||||||
- `srv/` — local dev runtime data
|
- `srv/` — local dev runtime data
|
||||||
- `gen/` — regenerated from proto, not hand-edited
|
- `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
|
## Critical Rules
|
||||||
|
|
||||||
1. **REST/gRPC sync**: Every REST endpoint must have a corresponding gRPC
|
1. **REST/gRPC sync**: Every REST endpoint must have a corresponding gRPC
|
||||||
|
|||||||
19
Dockerfile
19
Dockerfile
@@ -13,11 +13,26 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}"
|
|||||||
|
|
||||||
FROM alpine:3.21
|
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
|
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
|
USER mcns
|
||||||
EXPOSE 53/udp 53/tcp 8443 9443
|
|
||||||
|
|
||||||
ENTRYPOINT ["mcns"]
|
ENTRYPOINT ["mcns"]
|
||||||
CMD ["server", "--config", "/srv/mcns/mcns.toml"]
|
CMD ["server", "--config", "/srv/mcns/mcns.toml"]
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -18,8 +18,8 @@ lint:
|
|||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
proto:
|
proto:
|
||||||
protoc --go_out=. --go_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/kyle/mcns \
|
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/mc/mcns \
|
||||||
proto/mcns/v1/*.proto
|
proto/mcns/v1/*.proto
|
||||||
|
|
||||||
proto-lint:
|
proto-lint:
|
||||||
|
|||||||
42
README.md
Normal file
42
README.md
Normal 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
264
RUNBOOK.md
Normal 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).
|
||||||
@@ -16,14 +16,14 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
|
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/config"
|
"git.wntrmute.dev/mc/mcns/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
mcnsdns "git.wntrmute.dev/kyle/mcns/internal/dns"
|
mcnsdns "git.wntrmute.dev/mc/mcns/internal/dns"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/grpcserver"
|
"git.wntrmute.dev/mc/mcns/internal/grpcserver"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/server"
|
"git.wntrmute.dev/mc/mcns/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
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.
|
// Graceful shutdown on SIGINT/SIGTERM.
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -151,6 +171,7 @@ func runServer(configPath string) error {
|
|||||||
|
|
||||||
// Start gRPC server.
|
// Start gRPC server.
|
||||||
if grpcSrv != nil {
|
if grpcSrv != nil {
|
||||||
|
grpcServeStarted = true
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("gRPC server listening", "addr", grpcLis.Addr())
|
logger.Info("gRPC server listening", "addr", grpcLis.Addr())
|
||||||
errCh <- grpcSrv.Serve(grpcLis)
|
errCh <- grpcSrv.Serve(grpcLis)
|
||||||
@@ -169,22 +190,11 @@ func runServer(configPath string) error {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
|
shutdownAll()
|
||||||
return fmt.Errorf("server error: %w", err)
|
return fmt.Errorf("server error: %w", err)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
logger.Info("shutting down")
|
logger.Info("shutting down")
|
||||||
dnsServer.Shutdown()
|
shutdownAll()
|
||||||
if grpcSrv != nil {
|
|
||||||
grpcSrv.GracefulStop()
|
|
||||||
}
|
|
||||||
shutdownTimeout := 30 * time.Second
|
|
||||||
if cfg.Server.ShutdownTimeout.Duration > 0 {
|
|
||||||
shutdownTimeout = cfg.Server.ShutdownTimeout.Duration
|
|
||||||
}
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
||||||
defer cancel()
|
|
||||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
|
||||||
return fmt.Errorf("shutdown: %w", err)
|
|
||||||
}
|
|
||||||
logger.Info("mcns stopped")
|
logger.Info("mcns stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
17
deploy/mcns-rift.toml
Normal file
17
deploy/mcns-rift.toml
Normal 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
47
deploy/scripts/install.sh
Executable 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"
|
||||||
25
deploy/systemd/mcns-backup.service
Normal file
25
deploy/systemd/mcns-backup.service
Normal 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
|
||||||
10
deploy/systemd/mcns-backup.timer
Normal file
10
deploy/systemd/mcns-backup.timer
Normal 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
|
||||||
34
deploy/systemd/mcns.service
Normal file
34
deploy/systemd/mcns.service
Normal 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
|
||||||
@@ -110,7 +110,7 @@ const file_proto_mcns_v1_admin_proto_rawDesc = "" +
|
|||||||
"\x0eHealthResponse\x12\x16\n" +
|
"\x0eHealthResponse\x12\x16\n" +
|
||||||
"\x06status\x18\x01 \x01(\tR\x06status2I\n" +
|
"\x06status\x18\x01 \x01(\tR\x06status2I\n" +
|
||||||
"\fAdminService\x129\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 (
|
var (
|
||||||
file_proto_mcns_v1_admin_proto_rawDescOnce sync.Once
|
file_proto_mcns_v1_admin_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ const file_proto_mcns_v1_auth_proto_rawDesc = "" +
|
|||||||
"\x0eLogoutResponse2\x80\x01\n" +
|
"\x0eLogoutResponse2\x80\x01\n" +
|
||||||
"\vAuthService\x126\n" +
|
"\vAuthService\x126\n" +
|
||||||
"\x05Login\x12\x15.mcns.v1.LoginRequest\x1a\x16.mcns.v1.LoginResponse\x129\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 (
|
var (
|
||||||
file_proto_mcns_v1_auth_proto_rawDescOnce sync.Once
|
file_proto_mcns_v1_auth_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -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" +
|
"\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" +
|
"\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" +
|
"\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 (
|
var (
|
||||||
file_proto_mcns_v1_record_proto_rawDescOnce sync.Once
|
file_proto_mcns_v1_record_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ const file_proto_mcns_v1_zone_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"UpdateZone\x12\x1a.mcns.v1.UpdateZoneRequest\x1a\r.mcns.v1.Zone\x12E\n" +
|
"UpdateZone\x12\x1a.mcns.v1.UpdateZoneRequest\x1a\r.mcns.v1.Zone\x12E\n" +
|
||||||
"\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 (
|
var (
|
||||||
file_proto_mcns_v1_zone_proto_rawDescOnce sync.Once
|
file_proto_mcns_v1_zone_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -1,9 +1,9 @@
|
|||||||
module git.wntrmute.dev/kyle/mcns
|
module git.wntrmute.dev/mc/mcns
|
||||||
|
|
||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
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/go-chi/chi/v5 v5.2.5
|
||||||
github.com/miekg/dns v1.1.66
|
github.com/miekg/dns v1.1.66
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
|||||||
git.wntrmute.dev/kyle/mcdsl v1.0.0 h1:YB7dx4gdNYKKcVySpL6UkwHqdCJ9Nl1yS0+eHk0hNtk=
|
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
|
||||||
git.wntrmute.dev/kyle/mcdsl v1.0.0/go.mod h1:wo0tGfUAxci3XnOe4/rFmR0RjUElKdYUazc+Np986sg=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
mcdslconfig "git.wntrmute.dev/kyle/mcdsl/config"
|
mcdslconfig "git.wntrmute.dev/mc/mcdsl/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the top-level MCNS configuration.
|
// Config is the top-level MCNS configuration.
|
||||||
@@ -45,5 +45,11 @@ func (c *Config) Validate() error {
|
|||||||
if c.MCIAS.ServerURL == "" {
|
if c.MCIAS.ServerURL == "" {
|
||||||
return fmt.Errorf("mcias.server_url is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
|
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DB wraps a SQLite database connection.
|
// DB wraps a SQLite database connection.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db"
|
mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Migrations is the ordered list of MCNS schema migrations.
|
// 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",
|
Name: "seed zones and records from CoreDNS zone files",
|
||||||
SQL: `
|
SQL: `
|
||||||
-- Zone: svc.mcp.metacircular.net (service addresses)
|
-- 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);
|
VALUES (1, 'svc.mcp.metacircular.net', 'ns.mcp.metacircular.net.', 'admin.metacircular.net.', 3600, 600, 86400, 300, 2026032601);
|
||||||
|
|
||||||
-- Zone: mcp.metacircular.net (node addresses)
|
-- 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);
|
VALUES (2, 'mcp.metacircular.net', 'ns.mcp.metacircular.net.', 'admin.metacircular.net.', 3600, 600, 86400, 300, 2026032501);
|
||||||
|
|
||||||
-- svc.mcp.metacircular.net records
|
-- svc.mcp.metacircular.net records
|
||||||
INSERT 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', '192.168.88.181', 300);
|
||||||
INSERT 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, '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 OR IGNORE 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 OR IGNORE 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 OR IGNORE 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 OR IGNORE 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 OR IGNORE 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, 'mcp-agent', 'A', '100.95.252.120', 300);
|
||||||
|
|
||||||
-- mcp.metacircular.net records
|
-- mcp.metacircular.net records
|
||||||
INSERT 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', '192.168.88.181', 300);
|
||||||
INSERT 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, '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 OR IGNORE 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, 'ns', 'A', '100.95.252.120', 300);`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
internal/db/migrate_test.go
Normal file
47
internal/db/migrate_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,24 @@ func (d *DB) ZoneNames() ([]string, error) {
|
|||||||
return names, rows.Err()
|
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.
|
// nextSerial computes the next SOA serial in YYYYMMDDNN format.
|
||||||
func nextSerial(current int64) int64 {
|
func nextSerial(current int64) int64 {
|
||||||
today := time.Now().UTC()
|
today := time.Now().UTC()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/miekg/dns"
|
"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.
|
// 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)
|
relName = strings.TrimSuffix(qname, "."+zoneFQDN)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SOA queries.
|
// SOA queries always return the zone apex SOA regardless of query name.
|
||||||
if qtype == dns.TypeSOA || relName == "@" && qtype == dns.TypeSOA {
|
if qtype == dns.TypeSOA {
|
||||||
soa := s.buildSOA(zone)
|
soa := s.buildSOA(zone)
|
||||||
s.writeResponse(w, r, dns.RcodeSuccess, []dns.RR{soa}, nil)
|
s.writeResponse(w, r, dns.RcodeSuccess, []dns.RR{soa}, nil)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package grpcserver
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
|
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminService struct {
|
type adminService struct {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"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 {
|
type authService struct {
|
||||||
|
|||||||
815
internal/grpcserver/handlers_test.go
Normal file
815
internal/grpcserver/handlers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package grpcserver
|
package grpcserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
|
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// methodMap builds the mcdsl grpcserver.MethodMap for MCNS.
|
// methodMap builds the mcdsl grpcserver.MethodMap for MCNS.
|
||||||
|
|||||||
@@ -3,22 +3,28 @@ package grpcserver
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
|
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type recordService struct {
|
type recordService struct {
|
||||||
pb.UnimplementedRecordServiceServer
|
pb.UnimplementedRecordServiceServer
|
||||||
db *db.DB
|
db *db.DB
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordService) ListRecords(_ context.Context, req *pb.ListRecordsRequest) (*pb.ListRecordsResponse, error) {
|
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)
|
records, err := s.db.ListRecords(req.Zone, req.Name, req.Type)
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "zone not found")
|
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{}
|
resp := &pb.ListRecordsResponse{}
|
||||||
for _, r := range records {
|
for _, r := range records {
|
||||||
resp.Records = append(resp.Records, recordToProto(r))
|
resp.Records = append(resp.Records, s.recordToProto(r))
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordService) GetRecord(_ context.Context, req *pb.GetRecordRequest) (*pb.Record, error) {
|
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)
|
record, err := s.db.GetRecord(req.Id)
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "record not found")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "failed to get record")
|
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) {
|
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))
|
record, err := s.db.CreateRecord(req.Zone, req.Name, req.Type, req.Value, int(req.Ttl))
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "zone not found")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
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) {
|
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))
|
record, err := s.db.UpdateRecord(req.Id, req.Name, req.Type, req.Value, int(req.Ttl))
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "record not found")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
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) {
|
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)
|
err := s.db.DeleteRecord(req.Id)
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "record not found")
|
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
|
return &pb.DeleteRecordResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordToProto(r db.Record) *pb.Record {
|
func (s *recordService) recordToProto(r db.Record) *pb.Record {
|
||||||
return &pb.Record{
|
return &pb.Record{
|
||||||
Id: r.ID,
|
Id: r.ID,
|
||||||
Zone: r.ZoneName,
|
Zone: r.ZoneName,
|
||||||
@@ -92,14 +132,15 @@ func recordToProto(r db.Record) *pb.Record {
|
|||||||
Type: r.Type,
|
Type: r.Type,
|
||||||
Value: r.Value,
|
Value: r.Value,
|
||||||
Ttl: int32(r.TTL),
|
Ttl: int32(r.TTL),
|
||||||
CreatedAt: parseRecordTimestamp(r.CreatedAt),
|
CreatedAt: s.parseRecordTimestamp(r.CreatedAt),
|
||||||
UpdatedAt: parseRecordTimestamp(r.UpdatedAt),
|
UpdatedAt: s.parseRecordTimestamp(r.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRecordTimestamp(s string) *timestamppb.Timestamp {
|
func (s *recordService) parseRecordTimestamp(v string) *timestamppb.Timestamp {
|
||||||
t, err := parseTime(s)
|
t, err := parseTime(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Warn("failed to parse record timestamp", "value", v, "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return timestamppb.New(t)
|
return timestamppb.New(t)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
|
mcdslgrpc "git.wntrmute.dev/mc/mcdsl/grpcserver"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
|
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deps holds the dependencies injected into the gRPC server.
|
// 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.
|
// New creates a configured gRPC server with MCNS services registered.
|
||||||
func New(certFile, keyFile string, deps Deps, logger *slog.Logger) (*Server, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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.RegisterAdminServiceServer(srv.GRPCServer, &adminService{db: deps.DB})
|
||||||
pb.RegisterAuthServiceServer(srv.GRPCServer, &authService{auth: deps.Authenticator})
|
pb.RegisterAuthServiceServer(srv.GRPCServer, &authService{auth: deps.Authenticator})
|
||||||
pb.RegisterZoneServiceServer(srv.GRPCServer, &zoneService{db: deps.DB})
|
pb.RegisterZoneServiceServer(srv.GRPCServer, &zoneService{db: deps.DB, logger: logger})
|
||||||
pb.RegisterRecordServiceServer(srv.GRPCServer, &recordService{db: deps.DB})
|
pb.RegisterRecordServiceServer(srv.GRPCServer, &recordService{db: deps.DB, logger: logger})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ package grpcserver
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1"
|
pb "git.wntrmute.dev/mc/mcns/gen/mcns/v1"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type zoneService struct {
|
type zoneService struct {
|
||||||
pb.UnimplementedZoneServiceServer
|
pb.UnimplementedZoneServiceServer
|
||||||
db *db.DB
|
db *db.DB
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *zoneService) ListZones(_ context.Context, _ *pb.ListZonesRequest) (*pb.ListZonesResponse, error) {
|
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{}
|
resp := &pb.ListZonesResponse{}
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
resp.Zones = append(resp.Zones, zoneToProto(z))
|
resp.Zones = append(resp.Zones, s.zoneToProto(z))
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *zoneService) GetZone(_ context.Context, req *pb.GetZoneRequest) (*pb.Zone, error) {
|
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)
|
zone, err := s.db.GetZone(req.Name)
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "zone not found")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "failed to get zone")
|
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) {
|
func (s *zoneService) CreateZone(_ context.Context, req *pb.CreateZoneRequest) (*pb.Zone, error) {
|
||||||
refresh := int(req.Refresh)
|
if req.Name == "" {
|
||||||
if refresh == 0 {
|
return nil, status.Error(codes.InvalidArgument, "name is required")
|
||||||
refresh = 3600
|
|
||||||
}
|
}
|
||||||
retry := int(req.Retry)
|
if req.PrimaryNs == "" {
|
||||||
if retry == 0 {
|
return nil, status.Error(codes.InvalidArgument, "primary_ns is required")
|
||||||
retry = 600
|
|
||||||
}
|
}
|
||||||
expire := int(req.Expire)
|
if req.AdminEmail == "" {
|
||||||
if expire == 0 {
|
return nil, status.Error(codes.InvalidArgument, "admin_email is required")
|
||||||
expire = 86400
|
|
||||||
}
|
|
||||||
minTTL := int(req.MinimumTtl)
|
|
||||||
if minTTL == 0 {
|
|
||||||
minTTL = 300
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
zone, err := s.db.CreateZone(req.Name, req.PrimaryNs, req.AdminEmail, refresh, retry, expire, minTTL)
|
||||||
if errors.Is(err, db.ErrConflict) {
|
if errors.Is(err, db.ErrConflict) {
|
||||||
return nil, status.Error(codes.AlreadyExists, err.Error())
|
return nil, status.Error(codes.AlreadyExists, err.Error())
|
||||||
@@ -66,27 +67,22 @@ func (s *zoneService) CreateZone(_ context.Context, req *pb.CreateZoneRequest) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "failed to create zone")
|
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) {
|
func (s *zoneService) UpdateZone(_ context.Context, req *pb.UpdateZoneRequest) (*pb.Zone, error) {
|
||||||
refresh := int(req.Refresh)
|
if req.Name == "" {
|
||||||
if refresh == 0 {
|
return nil, status.Error(codes.InvalidArgument, "name is required")
|
||||||
refresh = 3600
|
|
||||||
}
|
}
|
||||||
retry := int(req.Retry)
|
if req.PrimaryNs == "" {
|
||||||
if retry == 0 {
|
return nil, status.Error(codes.InvalidArgument, "primary_ns is required")
|
||||||
retry = 600
|
|
||||||
}
|
}
|
||||||
expire := int(req.Expire)
|
if req.AdminEmail == "" {
|
||||||
if expire == 0 {
|
return nil, status.Error(codes.InvalidArgument, "admin_email is required")
|
||||||
expire = 86400
|
|
||||||
}
|
|
||||||
minTTL := int(req.MinimumTtl)
|
|
||||||
if minTTL == 0 {
|
|
||||||
minTTL = 300
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
zone, err := s.db.UpdateZone(req.Name, req.PrimaryNs, req.AdminEmail, refresh, retry, expire, minTTL)
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "zone not found")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "failed to update zone")
|
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) {
|
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)
|
err := s.db.DeleteZone(req.Name)
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
return nil, status.Error(codes.NotFound, "zone not found")
|
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
|
return &pb.DeleteZoneResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func zoneToProto(z db.Zone) *pb.Zone {
|
func (s *zoneService) zoneToProto(z db.Zone) *pb.Zone {
|
||||||
return &pb.Zone{
|
return &pb.Zone{
|
||||||
Id: z.ID,
|
Id: z.ID,
|
||||||
Name: z.Name,
|
Name: z.Name,
|
||||||
@@ -119,15 +119,15 @@ func zoneToProto(z db.Zone) *pb.Zone {
|
|||||||
Expire: int32(z.Expire),
|
Expire: int32(z.Expire),
|
||||||
MinimumTtl: int32(z.MinimumTTL),
|
MinimumTtl: int32(z.MinimumTTL),
|
||||||
Serial: z.Serial,
|
Serial: z.Serial,
|
||||||
CreatedAt: parseTimestamp(z.CreatedAt),
|
CreatedAt: s.parseTimestamp(z.CreatedAt),
|
||||||
UpdatedAt: parseTimestamp(z.UpdatedAt),
|
UpdatedAt: s.parseTimestamp(z.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseTimestamp(s string) *timestamppb.Timestamp {
|
func (s *zoneService) parseTimestamp(v string) *timestamppb.Timestamp {
|
||||||
// SQLite stores as "2006-01-02T15:04:05Z".
|
t, err := parseTime(v)
|
||||||
t, err := parseTime(s)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Warn("failed to parse zone timestamp", "value", v, "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return timestamppb.New(t)
|
return timestamppb.New(t)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// openTestDB creates a temporary SQLite database with all migrations applied.
|
// openTestDB creates a temporary SQLite database with all migrations applied.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type createRecordRequest struct {
|
type createRecordRequest struct {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
|
||||||
"git.wntrmute.dev/kyle/mcdsl/health"
|
"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.
|
// Deps holds dependencies injected into the REST handlers.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcns/internal/db"
|
"git.wntrmute.dev/mc/mcns/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type createZoneRequest struct {
|
type createZoneRequest struct {
|
||||||
@@ -72,18 +72,7 @@ func createZoneHandler(database *db.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply defaults for SOA params.
|
// Apply defaults for SOA params.
|
||||||
if req.Refresh == 0 {
|
req.Refresh, req.Retry, req.Expire, req.MinimumTTL = db.ApplySOADefaults(req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
|
||||||
req.Refresh = 3600
|
|
||||||
}
|
|
||||||
if req.Retry == 0 {
|
|
||||||
req.Retry = 600
|
|
||||||
}
|
|
||||||
if req.Expire == 0 {
|
|
||||||
req.Expire = 86400
|
|
||||||
}
|
|
||||||
if req.MinimumTTL == 0 {
|
|
||||||
req.MinimumTTL = 300
|
|
||||||
}
|
|
||||||
|
|
||||||
zone, err := database.CreateZone(req.Name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
|
zone, err := database.CreateZone(req.Name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
|
||||||
if errors.Is(err, db.ErrConflict) {
|
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")
|
writeError(w, http.StatusBadRequest, "admin_email is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Refresh == 0 {
|
req.Refresh, req.Retry, req.Expire, req.MinimumTTL = db.ApplySOADefaults(req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
|
||||||
req.Refresh = 3600
|
|
||||||
}
|
|
||||||
if req.Retry == 0 {
|
|
||||||
req.Retry = 600
|
|
||||||
}
|
|
||||||
if req.Expire == 0 {
|
|
||||||
req.Expire = 86400
|
|
||||||
}
|
|
||||||
if req.MinimumTTL == 0 {
|
|
||||||
req.MinimumTTL = 300
|
|
||||||
}
|
|
||||||
|
|
||||||
zone, err := database.UpdateZone(name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
|
zone, err := database.UpdateZone(name, req.PrimaryNS, req.AdminEmail, req.Refresh, req.Retry, req.Expire, req.MinimumTTL)
|
||||||
if errors.Is(err, db.ErrNotFound) {
|
if errors.Is(err, db.ErrNotFound) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package mcns.v1;
|
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.
|
// AdminService exposes server health and administrative operations.
|
||||||
service AdminService {
|
service AdminService {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package mcns.v1;
|
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.
|
// AuthService handles authentication by delegating to MCIAS.
|
||||||
service AuthService {
|
service AuthService {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package mcns.v1;
|
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";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package mcns.v1;
|
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";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user