Files
DDI/server/internal/httpapi/zones_test.go
T

648 lines
30 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/server/internal/events"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
type (
clusterList = listResponse[service.ClusterDTO]
keyList = listResponse[service.TSIGKeyDTO]
zoneList = listResponse[service.ZoneDTO]
recordList = listResponse[service.RecordDTO]
)
// problemCodes returns the top-level code and the codes of the problems.
func problemCodes(t *testing.T, r *response) []string {
t.Helper()
p := r.problem(t)
out := []string{p.Code}
for _, pr := range p.Problems {
out = append(out, pr.Code+"@"+pr.Field)
}
return out
}
func wantCodes(t *testing.T, r *response, want ...string) {
t.Helper()
got := problemCodes(t, r)
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("problem codes %v, want %v (%s)", got, want, r.Body)
}
}
// auditJSON returns the whole audit log as JSON lines.
func (c *client) auditJSON() string {
c.e.t.Helper()
return string(c.mustStatus(200, "GET", "/api/v1/audit/export?format=json", nil).Body)
}
func TestTSIGKeys(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
operator := e.userClient(admin, "otto", "operator")
viewer := e.userClient(admin, "vera", "viewer")
var k service.TSIGKeyDTO
admin.mustStatus(201, "POST", "/api/v1/tsig-keys", map[string]any{"name": "DHCP-Update.Example.Test.", "description": "Kea"}).json(t, &k)
if k.Name != "dhcp-update.example.test" || k.Algorithm != "hmac-sha256" || k.ClusterID != nil || k.Version != 1 {
t.Fatalf("key %+v", k)
}
operator.mustStatus(403, "POST", "/api/v1/tsig-keys", map[string]any{"name": "x"})
wantCodes(t, admin.mustStatus(409, "POST", "/api/v1/tsig-keys", map[string]any{"name": "dhcp-update.example.test"}),
"key_exists", "key_exists@name")
wantCodes(t, admin.mustStatus(422, "POST", "/api/v1/tsig-keys", map[string]any{"name": "bad name"}),
"validation_failed", "invalid_key_name@name")
var list keyList
viewer.mustStatus(200, "GET", "/api/v1/tsig-keys", nil).json(t, &list)
if len(list.Items) != 1 {
t.Fatalf("keys %+v", list.Items)
}
viewer.mustStatus(403, "GET", "/api/v1/tsig-keys/"+k.ID+"/secret", nil)
operator.mustStatus(403, "GET", "/api/v1/tsig-keys/"+k.ID+"/secret", nil)
var sec service.TSIGKeySecretDTO
admin.mustStatus(200, "GET", "/api/v1/tsig-keys/"+k.ID+"/secret", nil).json(t, &sec)
raw, err := base64.StdEncoding.DecodeString(sec.Secret)
if err != nil || len(raw) != 32 || sec.Name != k.Name || sec.Algorithm != "hmac-sha256" {
t.Fatalf("secret %+v %v", sec, err)
}
var k512 service.TSIGKeyDTO
admin.mustStatus(201, "POST", "/api/v1/tsig-keys", map[string]any{"name": "big", "algorithm": "hmac-sha512"}).json(t, &k512)
var sec512 service.TSIGKeySecretDTO
admin.mustStatus(200, "GET", "/api/v1/tsig-keys/"+k512.ID+"/secret", nil).json(t, &sec512)
if raw, _ := base64.StdEncoding.DecodeString(sec512.Secret); len(raw) != 64 {
t.Errorf("hmac-sha512 secret has %d bytes", len(raw))
}
// Rotation replaces the secret.
e.advance(time.Minute)
var rotated service.TSIGKeyDTO
operator.mustStatus(403, "POST", "/api/v1/tsig-keys/"+k.ID+"/rotate", nil)
admin.mustStatus(200, "POST", "/api/v1/tsig-keys/"+k.ID+"/rotate", nil).json(t, &rotated)
if !rotated.RotatedAt.After(k.RotatedAt) || rotated.Version != 2 {
t.Errorf("rotated %+v", rotated)
}
var sec2 service.TSIGKeySecretDTO
admin.mustStatus(200, "GET", "/api/v1/tsig-keys/"+k.ID+"/secret", nil).json(t, &sec2)
if sec2.Secret == sec.Secret {
t.Error("rotation kept the secret")
}
// Description only; stale versions fail.
var upd service.TSIGKeyDTO
admin.mustStatus(200, "PUT", "/api/v1/tsig-keys/"+k.ID, map[string]any{"name": "ignored", "description": "Kea DDNS",
"version": 2}).json(t, &upd)
if upd.Name != k.Name || upd.Description != "Kea DDNS" || upd.Version != 3 {
t.Errorf("updated %+v", upd)
}
admin.mustStatus(409, "PUT", "/api/v1/tsig-keys/"+k.ID, map[string]any{"description": "x", "version": 2})
admin.mustStatus(404, "PUT", "/api/v1/tsig-keys/nonsense", map[string]any{"version": 1})
// Keys used by zones cannot be deleted.
var z service.ZoneDTO
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "dyn.example.test", "mode": "dynamic",
"update_key_ids": []string{k.ID}}).json(t, &z)
wantCodes(t, admin.mustStatus(409, "DELETE", "/api/v1/tsig-keys/"+k.ID, nil), "key_in_use")
list = keyList{}
viewer.mustStatus(200, "GET", "/api/v1/tsig-keys", nil).json(t, &list)
if list.Items[1].Name != k.Name || list.Items[1].ZoneCount != 1 {
t.Errorf("zone count %+v", list.Items)
}
admin.mustStatus(204, "DELETE", "/api/v1/zones/"+z.ID, nil)
operator.mustStatus(403, "DELETE", "/api/v1/tsig-keys/"+k.ID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/tsig-keys/"+k.ID, nil)
admin.mustStatus(404, "DELETE", "/api/v1/tsig-keys/"+k.ID, nil)
// Every secret access is audited, no secret ever reaches the log.
audit := admin.auditJSON()
if strings.Count(audit, `"tsig_key.read_secret"`) != 3 {
t.Errorf("secret reads not audited:\n%s", audit)
}
for _, s := range []string{sec.Secret, sec2.Secret, sec512.Secret} {
if strings.Contains(audit, s) {
t.Fatal("secret in the audit log")
}
}
// Key changes count as pending changes, secret reads do not.
var p service.PendingChanges
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
for _, c := range p.Changes {
if c.Action == "tsig_key.read_secret" {
t.Errorf("secret read counted as change")
}
}
if len(p.Changes) == 0 || p.Changes[0].Action != "tsig_key.delete" {
t.Errorf("changes %+v", p.Changes)
}
}
func TestClusters(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
operator := e.userClient(admin, "otto", "operator")
viewer := e.userClient(admin, "vera", "viewer")
dns1 := e.enroll(admin.createToken("dns").Secret, "dns")
dns2 := e.enroll(admin.createToken("dns").Secret, "dns")
dns3 := e.enroll(admin.createToken("dns").Secret, "dns")
dns4 := e.enroll(admin.createToken("dns").Secret, "dns")
dhcp1 := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
dhcp2 := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
dhcp3 := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
member := func(id, role, addr string) map[string]any {
return map[string]any{"agent_id": id, "role": role, "address": addr}
}
operator.mustStatus(403, "POST", "/api/v1/clusters", map[string]any{"name": "x", "role": "dns"})
var dnsC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "Office DNS", "role": "dns",
"members": []any{member(dns1, "primary", "192.0.2.53"), member(dns2, "secondary", "192.0.2.54")},
"primary_ns": "NS1.Office.Test", "name_servers": []map[string]any{{"name": "ns1.office.test", "addresses": []string{"192.0.2.53"}}},
"ha_mode": "load-balancing", "description": "Primary and secondary",
}).json(t, &dnsC)
if dnsC.Role != "dns" || len(dnsC.Members) != 2 || dnsC.Members[0].AgentID != dns1 || dnsC.Members[0].Role != "primary" ||
dnsC.Members[0].Hostname != "host-dns" || dnsC.PrimaryNS != "ns1.office.test" || dnsC.TransferKey == nil ||
dnsC.HAMode != "hot-standby" || dnsC.HAPort != 8001 || len(dnsC.NameServers) != 1 {
t.Fatalf("dns cluster %+v", dnsC)
}
// The transfer key is a TSIG key owned by the cluster.
var keys keyList
viewer.mustStatus(200, "GET", "/api/v1/tsig-keys", nil).json(t, &keys)
if len(keys.Items) != 1 || keys.Items[0].ID != dnsC.TransferKey.ID || keys.Items[0].ClusterID == nil ||
*keys.Items[0].ClusterID != dnsC.ID {
t.Fatalf("transfer key %+v", keys.Items)
}
wantCodes(t, admin.mustStatus(409, "DELETE", "/api/v1/tsig-keys/"+dnsC.TransferKey.ID, nil), "key_owned_by_cluster")
var dhcpC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "Office DHCP", "role": "dhcp",
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68")},
"ha_port": 8100}).json(t, &dhcpC)
if dhcpC.HAMode != "hot-standby" || dhcpC.HAPort != 8100 || dhcpC.TransferKey != nil || len(dhcpC.Members) != 2 {
t.Fatalf("dhcp cluster %+v", dhcpC)
}
// Membership errors.
cases := []struct {
name string
members []any
role string
want []string
}{
{"role mismatch", []any{member(dhcp3, "primary", "192.0.2.1")}, "dns", []string{"validation_failed", "cluster_role_mismatch@members[0].agent_id"}},
{"other cluster", []any{member(dns1, "primary", "192.0.2.1")}, "dns", []string{"validation_failed", "agent_in_other_cluster@members[0].agent_id"}},
{"second primary", []any{member(dns3, "primary", "192.0.2.1"), member(dns4, "primary", "192.0.2.2")}, "dns",
[]string{"validation_failed", "cluster_primary_exists@members[1].role"}},
{"unknown agent", []any{member(dhcp3, "secondary", "192.0.2.1"), member("00000000-0000-4000-8000-000000000001", "secondary", "192.0.2.2")}, "dhcp",
[]string{"validation_failed", "unknown_agent@members[1].agent_id"}},
{"invalid ip", []any{member(dns3, "primary", "not-an-ip")}, "dns", []string{"validation_failed", "invalid_ip@members[0].address"}},
{"duplicate", []any{member(dns3, "primary", "192.0.2.1"), member(dns3, "secondary", "192.0.2.2")}, "dns",
[]string{"validation_failed", "duplicate_member@members[1].agent_id"}},
}
for _, c := range cases {
r := admin.mustStatus(422, "POST", "/api/v1/clusters", map[string]any{"name": "Bad " + c.name, "role": c.role, "members": c.members})
wantCodes(t, r, c.want...)
}
wantCodes(t, admin.mustStatus(422, "POST", "/api/v1/clusters", map[string]any{"name": "DHCP 2", "role": "dhcp",
"members": []any{member(dhcp3, "primary", "192.0.2.1"), member(dhcp1, "primary", "192.0.2.2")}}),
"validation_failed", "agent_in_other_cluster@members[1].agent_id")
wantCodes(t, admin.mustStatus(409, "POST", "/api/v1/clusters", map[string]any{"name": "office dns", "role": "dns"}),
"cluster_exists", "cluster_exists@name")
// A DHCP pair has at most one secondary.
var dhcpUpd service.ClusterDTO
r := admin.mustStatus(422, "PUT", "/api/v1/clusters/"+dhcpC.ID, map[string]any{"name": "Office DHCP", "version": dhcpC.Version,
"members": []any{member(dhcp1, "primary", "192.0.2.67"), member(dhcp2, "secondary", "192.0.2.68"),
member(dhcp3, "secondary", "192.0.2.69")}})
wantCodes(t, r, "validation_failed", "cluster_member_limit@members[2].role")
// Agents show their cluster.
if ag := viewer.agent(dns2); ag.Cluster == nil || ag.Cluster.ID != dnsC.ID || ag.Cluster.Role != "secondary" ||
ag.Cluster.Address.String() != "192.0.2.54" || ag.Cluster.Name != "Office DNS" {
t.Errorf("agent cluster %+v", ag.Cluster)
}
if ag := viewer.agent(dns3); ag.Cluster != nil {
t.Errorf("agent without cluster %+v", ag.Cluster)
}
// Update: the role is immutable, members are replaced.
viewer.mustStatus(403, "PUT", "/api/v1/clusters/"+dnsC.ID, map[string]any{"name": "x", "version": 1})
admin.mustStatus(200, "PUT", "/api/v1/clusters/"+dhcpC.ID, map[string]any{"name": "Office DHCP", "role": "dns",
"version": dhcpC.Version, "members": []any{member(dhcp1, "primary", "192.0.2.67")}}).json(t, &dhcpUpd)
if dhcpUpd.Role != "dhcp" || len(dhcpUpd.Members) != 1 || dhcpUpd.Version != 2 || dhcpUpd.HAPort != 8001 {
t.Errorf("updated %+v", dhcpUpd)
}
admin.mustStatus(409, "PUT", "/api/v1/clusters/"+dhcpC.ID, map[string]any{"name": "x", "version": 1})
if ag := viewer.agent(dhcp2); ag.Cluster != nil {
t.Errorf("removed member still in cluster: %+v", ag.Cluster)
}
var list clusterList
viewer.mustStatus(200, "GET", "/api/v1/clusters", nil).json(t, &list)
if len(list.Items) != 2 || list.Items[0].Role != "dns" {
t.Errorf("clusters %+v", list.Items)
}
list = clusterList{}
viewer.mustStatus(200, "GET", "/api/v1/clusters?role=dhcp", nil).json(t, &list)
if len(list.Items) != 1 || list.Items[0].ID != dhcpC.ID {
t.Errorf("dhcp clusters %+v", list.Items)
}
// Networks accept DHCP clusters only.
netReq := map[string]any{"cidr": "192.0.2.0/24", "name": "Office", "dhcp_cluster_id": dnsC.ID}
wantCodes(t, admin.mustStatus(422, "POST", "/api/v1/networks", netReq), "validation_failed", "cluster_role_mismatch@dhcp_cluster_id")
netReq["dhcp_cluster_id"] = "00000000-0000-4000-8000-000000000001"
wantCodes(t, admin.mustStatus(422, "POST", "/api/v1/networks", netReq), "validation_failed", "not_found@dhcp_cluster_id")
netReq["dhcp_cluster_id"] = dhcpC.ID
var netw service.NetworkDTO
admin.mustStatus(201, "POST", "/api/v1/networks", netReq).json(t, &netw)
if netw.DHCPClusterID == nil || *netw.DHCPClusterID != dhcpC.ID {
t.Errorf("network cluster %v", netw.DHCPClusterID)
}
// Generator settings: default clusters must exist and match the role.
var g service.GeneratorSettings
admin.mustStatus(200, "GET", "/api/v1/settings/generator", nil).json(t, &g)
if g.DHCP.HAPasswordFile != "/etc/kea/ddi-suite-ha-password" || g.DNS.DefaultClusterID != nil {
t.Errorf("settings %+v", g)
}
g.DNS.DefaultClusterID = &dhcpC.ID
g.DHCP.HAPasswordFile = "relative/path"
r = admin.mustStatus(422, "PUT", "/api/v1/settings/generator", g)
wantCodes(t, r, "validation_failed", "invalid_path@dhcp.ha_password_file")
g.DHCP.HAPasswordFile = "/etc/kea/ha-password"
wantCodes(t, admin.mustStatus(422, "PUT", "/api/v1/settings/generator", g), "validation_failed",
"cluster_role_mismatch@dns.default_cluster_id")
g.DNS.DefaultClusterID, g.DHCP.DefaultClusterID = &dnsC.ID, &dhcpC.ID
g = service.GeneratorSettings{DNS: g.DNS, DHCP: g.DHCP}
admin.mustStatus(200, "PUT", "/api/v1/settings/generator", g).json(t, &g)
if g.DNS.DefaultClusterID == nil || *g.DNS.DefaultClusterID != dnsC.ID || g.DHCP.HAPasswordFile != "/etc/kea/ha-password" {
t.Errorf("saved settings %+v", g)
}
// Clusters in use cannot be deleted.
r = admin.mustStatus(409, "DELETE", "/api/v1/clusters/"+dhcpC.ID, nil)
if p := r.problem(t); p.Code != "cluster_in_use" || p.Params["networks"] != "1" || p.Params["default"] != "true" {
t.Errorf("in use %+v", p)
}
g.DNS.DefaultClusterID, g.DHCP.DefaultClusterID = nil, nil
admin.mustStatus(200, "PUT", "/api/v1/settings/generator", g)
var z service.ZoneDTO
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "office.test", "cluster_id": dnsC.ID}).json(t, &z)
r = admin.mustStatus(409, "DELETE", "/api/v1/clusters/"+dnsC.ID, nil)
if p := r.problem(t); p.Code != "cluster_in_use" || p.Params["zones"] != "1" {
t.Errorf("in use %+v", p)
}
admin.mustStatus(204, "DELETE", "/api/v1/zones/"+z.ID, nil)
viewer.mustStatus(403, "DELETE", "/api/v1/clusters/"+dnsC.ID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/clusters/"+dnsC.ID, nil)
admin.mustStatus(404, "GET", "/api/v1/clusters/"+dnsC.ID, nil)
keys = keyList{}
viewer.mustStatus(200, "GET", "/api/v1/tsig-keys", nil).json(t, &keys)
if len(keys.Items) != 0 {
t.Errorf("transfer key survived its cluster: %+v", keys.Items)
}
if ag := viewer.agent(dns1); ag.Cluster != nil {
t.Errorf("member of deleted cluster %+v", ag.Cluster)
}
// Audit: every change, never HA credentials; all count as changes.
audit := admin.auditJSON()
for _, want := range []string{`"cluster.create"`, `"cluster.update"`, `"cluster.delete"`, `"tsig_key.create"`} {
if !strings.Contains(audit, want) {
t.Errorf("audit misses %s", want)
}
}
for _, line := range strings.Split(strings.TrimSpace(audit), "\n") {
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Fatal(err)
}
if strings.Contains(line, "ha_user") || strings.Contains(line, "ha_password\"") || strings.Contains(line, "secret\"") {
t.Errorf("credentials in the audit log: %s", line)
}
}
var p service.PendingChanges
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
if len(p.Changes) == 0 || p.Changes[0].Action != "cluster.delete" {
t.Errorf("pending %+v", p.Changes)
}
}
func TestZonesAndRecords(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
operator := e.userClient(admin, "otto", "operator")
viewer := e.userClient(admin, "vera", "viewer")
var z service.ZoneDTO
viewer.mustStatus(403, "POST", "/api/v1/zones", map[string]any{"name": "example.test"})
operator.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "Example.TEST.", "default_ttl": 600,
"description": "Main", "allow_update": []string{"192.0.2.0/24"}}).json(t, &z)
if z.Name != "example.test" || z.Kind != "forward" || z.Mode != "managed" || z.DefaultTTL == nil || *z.DefaultTTL != 600 ||
len(z.AllowUpdate) != 0 || z.ClusterID != nil {
t.Fatalf("zone %+v", z)
}
var rz service.ZoneDTO
operator.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "2.0.192.in-addr.arpa"}).json(t, &rz)
if rz.Kind != "reverse" {
t.Errorf("reverse zone %+v", rz)
}
wantCodes(t, operator.mustStatus(409, "POST", "/api/v1/zones", map[string]any{"name": "example.test"}), "zone_exists", "zone_exists@name")
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "example.test", "kind": "reverse"}),
"validation_failed", "zone_kind_mismatch@kind")
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "1.0.10.in-addr.arpa", "kind": "forward"}),
"validation_failed", "zone_kind_mismatch@kind")
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "300.10.in-addr.arpa"}),
"validation_failed", "invalid_domain@name")
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "_msdcs.example.test"}),
"validation_failed", "invalid_domain@name")
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "ad.example.test", "mode": "dynamic"}),
"validation_failed", "update_source_required@allow_update")
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "ad.example.test", "mode": "dynamic",
"update_key_ids": []string{"00000000-0000-4000-8000-000000000001"}}), "validation_failed", "not_found@update_key_ids")
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "ad.example.test",
"cluster_id": "00000000-0000-4000-8000-000000000001"}), "validation_failed", "not_found@cluster_id")
var dhcpC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "DHCP", "role": "dhcp"}).json(t, &dhcpC)
wantCodes(t, operator.mustStatus(422, "POST", "/api/v1/zones", map[string]any{"name": "ad.example.test", "cluster_id": dhcpC.ID}),
"validation_failed", "cluster_role_mismatch@cluster_id")
var ad service.ZoneDTO
operator.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "ad.example.test", "mode": "dynamic",
"allow_update": []string{"192.0.2.10/32", "192.0.2.10/32"}}).json(t, &ad)
if len(ad.AllowUpdate) != 1 || ad.AllowUpdate[0].String() != "192.0.2.10/32" {
t.Errorf("dynamic zone %+v", ad)
}
var adUpd service.ZoneDTO
operator.mustStatus(200, "PUT", "/api/v1/zones/"+ad.ID, map[string]any{"name": "ignored", "mode": "managed",
"allow_update": []string{"192.0.2.10/32"}, "version": 1}).json(t, &adUpd)
if adUpd.Name != "ad.example.test" || adUpd.Mode != "managed" || len(adUpd.AllowUpdate) != 0 || adUpd.Version != 2 {
t.Errorf("updated zone %+v", adUpd)
}
operator.mustStatus(409, "PUT", "/api/v1/zones/"+ad.ID, map[string]any{"version": 1})
var zones zoneList
viewer.mustStatus(200, "GET", "/api/v1/zones", nil).json(t, &zones)
if len(zones.Items) != 3 || zones.Items[0].Name != "2.0.192.in-addr.arpa" {
t.Errorf("zones %+v", zones.Items)
}
viewer.mustStatus(200, "GET", "/api/v1/zones/derived", nil)
viewer.mustStatus(404, "GET", "/api/v1/zones/nonsense", nil)
// Records.
base := "/api/v1/zones/" + z.ID + "/records"
recs := []map[string]any{
{"name": "www", "type": "A", "address": "192.0.2.10", "ttl": 300},
{"name": "www", "type": "AAAA", "address": "2001:db8::10"},
{"name": "@", "type": "MX", "target": "Mail.Example.Test.", "preference": 10},
{"name": "_ldap._tcp.ad", "type": "SRV", "target": "dc1.example.test", "priority": 0, "weight": 100, "port": 389},
{"name": "@", "type": "TXT", "text": []string{"v=spf1 -all", strings.Repeat("x", 300)}},
{"name": "alias", "type": "CNAME", "target": "_acme.example.test"},
{"name": "sub", "type": "NS", "target": "ns.sub.example.test"},
}
viewer.mustStatus(403, "POST", base, recs[0])
var created []service.RecordDTO
for _, rec := range recs {
var d service.RecordDTO
operator.mustStatus(201, "POST", base, rec).json(t, &d)
created = append(created, d)
}
if created[2].Target != "mail.example.test" || created[0].TTL == nil || *created[0].TTL != 300 || created[0].Address != "192.0.2.10" {
t.Errorf("records %+v", created)
}
wantCodes(t, operator.mustStatus(409, "POST", base, recs[0]), "record_exists", "record_exists@")
badCases := []struct {
rec map[string]any
want []string
}{
{map[string]any{"name": "x", "type": "AAAA", "address": "192.0.2.1"}, []string{"validation_failed", "ip_family@address"}},
{map[string]any{"name": "x", "type": "A", "address": "nonsense"}, []string{"validation_failed", "invalid_ip@address"}},
{map[string]any{"name": "@", "type": "CNAME", "target": "x.example.test"}, []string{"validation_failed", "cname_at_apex@name"}},
{map[string]any{"name": "www.example.test", "type": "A", "address": "192.0.2.1"}, []string{"validation_failed", "invalid_label@name"}},
{map[string]any{"name": "a b", "type": "A", "address": "192.0.2.1"}, []string{"validation_failed", "invalid_label@name"}},
{map[string]any{"name": "x", "type": "MX", "target": "mail.example.test", "port": 25}, []string{"validation_failed", "field_not_allowed@port"}},
{map[string]any{"name": "x", "type": "SRV", "target": "_bad.example.test"}, []string{"validation_failed", "invalid_domain@target"}},
{map[string]any{"name": "x", "type": "TXT"}, []string{"validation_failed", "invalid_txt@text"}},
{map[string]any{"name": "x", "type": "TXT", "text": []string{strings.Repeat("y", 70000)}}, []string{"validation_failed", "invalid_txt@text"}},
{map[string]any{"name": "x", "type": "A", "address": "192.0.2.1", "ttl": 0}, []string{"validation_failed", "invalid_ttl@ttl"}},
}
for _, c := range badCases {
wantCodes(t, operator.mustStatus(422, "POST", base, c.rec, skipRequestValidation, "1"), c.want...)
}
var list recordList
viewer.mustStatus(200, "GET", base, nil).json(t, &list)
if len(list.Items) != len(recs) || list.Items[0].Name != "@" {
t.Fatalf("records %+v", list.Items)
}
// Update and delete.
var upd service.RecordDTO
operator.mustStatus(200, "PUT", base+"/"+created[0].ID, map[string]any{"name": "web", "type": "A", "address": "192.0.2.11",
"version": 1}).json(t, &upd)
if upd.Name != "web" || upd.TTL != nil || upd.Version != 2 {
t.Errorf("updated record %+v", upd)
}
operator.mustStatus(409, "PUT", base+"/"+created[0].ID, map[string]any{"name": "web", "type": "A", "address": "192.0.2.11", "version": 1})
operator.mustStatus(404, "PUT", "/api/v1/zones/"+rz.ID+"/records/"+created[0].ID, map[string]any{"name": "web",
"type": "A", "address": "192.0.2.11", "version": 2})
operator.mustStatus(204, "DELETE", base+"/"+created[1].ID, nil)
operator.mustStatus(404, "DELETE", base+"/"+created[1].ID, nil)
// A zone with records needs force.
r := operator.mustStatus(409, "DELETE", "/api/v1/zones/"+z.ID, nil)
if p := r.problem(t); p.Code != "zone_not_empty" || p.Params["count"] != "6" {
t.Errorf("not empty %+v", p)
}
operator.mustStatus(204, "DELETE", "/api/v1/zones/"+z.ID+"?force=true", nil)
operator.mustStatus(404, "GET", base, nil)
// Every change is audited and pending.
var p service.PendingChanges
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
actions := map[string]int{}
for _, c := range p.Changes {
actions[c.Action]++
}
if actions["zone.create"] != 3 || actions["record.create"] != len(recs) || actions["record.update"] != 1 ||
actions["record.delete"] != 1 || actions["zone.delete"] != 1 || actions["zone.update"] != 1 {
t.Errorf("pending changes %v", actions)
}
}
func TestLeaseAdoption(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
ctx := context.Background()
var netw service.NetworkDTO
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.0/24", "name": "Office",
"domain": "office.test", "dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}},
}).json(t, &netw)
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.120", "type": "manual_dhcp",
"mac": "00:11:22:33:44:99", "hostname": "printer"})
ids, err := e.st.Q().SubnetIDs(ctx)
if err != nil {
t.Fatal(err)
}
subnet := ids[netw.ID]
agentID := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
sub := e.bus.Subscribe(16)
defer sub.Close()
exp := e.now.Add(time.Hour).UTC().Truncate(time.Second)
if _, err := e.svc.StoreLeaseBatch(ctx, agentID, 1, true, []service.LeaseInput{
{IP: "192.0.2.100", MAC: "00-11-22-33-44-55", Hostname: "Laptop", State: "active", ExpiresAt: &exp, SubnetID: subnet},
{IP: "192.0.2.101", MAC: "00:11:22:33:44:56", Hostname: "phone.example.com", State: "active", SubnetID: subnet},
{IP: "192.0.2.102", MAC: "00:11:22:33:44:57", Hostname: "gone", State: "expired", SubnetID: subnet},
{IP: "192.0.2.50", MAC: "00:11:22:33:44:58", Hostname: "outside", State: "active", SubnetID: subnet},
{IP: "192.0.2.120", MAC: "00:11:22:33:44:59", Hostname: "other", State: "active", ExpiresAt: &exp, SubnetID: subnet},
{IP: "198.51.100.7", State: "active", SubnetID: 9999},
}); err != nil {
t.Fatal(err)
}
select {
case ev := <-sub.C():
if ev.Type != events.TypeAddresses || !strings.Contains(string(ev.Data), netw.ID) {
t.Errorf("event %s %s", ev.Type, ev.Data)
}
case <-time.After(5 * time.Second):
t.Fatal("no addresses event")
}
addrs := func() map[string]service.AddressDTO {
var page service.AddressPage
viewer.mustStatus(200, "GET", "/api/v1/addresses?network_id="+netw.ID, nil).json(t, &page)
out := map[string]service.AddressDTO{}
for _, a := range page.Items {
out[a.IP.String()] = a
}
return out
}
got := addrs()
if len(got) != 3 {
t.Fatalf("addresses %+v", got)
}
a := got["192.0.2.100"]
if a.Type != "dynamic" || a.MAC != "00:11:22:33:44:55" || a.Hostname != "laptop" || a.FQDN != "laptop.office.test" ||
a.LeaseState != "active" || a.LeaseExpiresAt == nil || !a.LeaseExpiresAt.Equal(exp) || a.FirstSeen == nil || a.LastSeen == nil {
t.Errorf("adopted %+v", a)
}
if b := got["192.0.2.101"]; b.Type != "dynamic" || b.Hostname != "" || b.FQDN != "" {
t.Errorf("adopted without valid label %+v", b)
}
if p := got["192.0.2.120"]; p.Type != "manual_dhcp" || p.MAC != "00:11:22:33:44:99" || p.Hostname != "printer" ||
p.LeaseState != "active" || p.LastSeen == nil || p.Version != 1 {
t.Errorf("reservation %+v", p)
}
// Later reports update dynamic entries; expired leases only change the
// state, entries stay.
e.advance(time.Minute)
if _, err := e.svc.StoreLeaseBatch(ctx, agentID, 2, false, []service.LeaseInput{
{IP: "192.0.2.100", MAC: "00:11:22:33:44:aa", Hostname: "laptop2", State: "active", ExpiresAt: &exp, SubnetID: subnet},
{IP: "192.0.2.101", State: "expired", SubnetID: subnet},
}); err != nil {
t.Fatal(err)
}
got = addrs()
if a := got["192.0.2.100"]; a.MAC != "00:11:22:33:44:aa" || a.Hostname != "laptop2" || !a.LastSeen.After(*got["192.0.2.101"].LastSeen) {
t.Errorf("updated %+v", a)
}
if b := got["192.0.2.101"]; b.LeaseState != "expired" || b.MAC != "00:11:22:33:44:56" {
t.Errorf("expired %+v", b)
}
// A full sync without the leases deletes nothing.
if _, err := e.svc.StoreLeaseBatch(ctx, agentID, 3, true, nil); err != nil {
t.Fatal(err)
}
if got := addrs(); len(got) != 3 {
t.Errorf("entries deleted: %+v", got)
}
// No audit entries and no pending changes from leases.
audit := admin.auditJSON()
if strings.Count(audit, `"address.create"`) != 1 || strings.Contains(audit, "laptop") {
t.Errorf("lease adoption audited:\n%s", audit)
}
var p service.PendingChanges
viewer.mustStatus(200, "GET", "/api/v1/config/pending", nil).json(t, &p)
if len(p.Changes) != 2 {
t.Errorf("pending changes %+v", p.Changes)
}
// The adopted entry becomes a reservation by changing its type.
var res service.AddressDTO
admin.mustStatus(200, "PUT", "/api/v1/addresses/"+got["192.0.2.100"].ID, map[string]any{"ip": "192.0.2.100",
"type": "manual_dhcp", "mac": "00:11:22:33:44:aa", "hostname": "laptop2", "version": got["192.0.2.100"].Version}).json(t, &res)
if res.Type != "manual_dhcp" || res.LeaseState != "active" {
t.Errorf("reservation %+v", res)
}
}
// TestRevisionSecretsAndRollback: the generator input of a revision is
// stored without secrets; they are sealed separately and restored for a
// rollback.
func TestRevisionSecretsAndRollback(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
ctx := context.Background()
admin.configureDNS()
admin.officeNetwork()
var k service.TSIGKeyDTO
admin.mustStatus(201, "POST", "/api/v1/tsig-keys", map[string]any{"name": "ddns"}).json(t, &k)
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "ad.office.test", "mode": "dynamic",
"update_key_ids": []string{k.ID}})
var dhcpC service.ClusterDTO
admin.mustStatus(201, "POST", "/api/v1/clusters", map[string]any{"name": "DHCP", "role": "dhcp"}).json(t, &dhcpC)
var sec service.TSIGKeySecretDTO
admin.mustStatus(200, "GET", "/api/v1/tsig-keys/"+k.ID+"/secret", nil).json(t, &sec)
rev := admin.newRevision()
if len(rev.Targets) < 2 || rev.Targets[0].Role != "dns" || rev.Targets[0].Target != "" || rev.Targets[0].ClusterID != nil {
t.Errorf("targets %+v", rev.Targets)
}
src, sealed, err := e.st.Q().RevisionSource(ctx, rev.Number)
if err != nil || len(sealed) == 0 {
t.Fatalf("source %v, secrets %d bytes", err, len(sealed))
}
if strings.Contains(string(src), sec.Secret) || strings.Contains(string(sealed), sec.Secret) {
t.Fatal("secret stored in plain text")
}
var stored struct {
Version int
Keys []map[string]any
Zones []map[string]any
}
if err := json.Unmarshal(src, &stored); err != nil || stored.Version != 2 || len(stored.Keys) != 1 || len(stored.Zones) != 1 {
t.Errorf("stored source %+v %v", stored, err)
}
// Rollback works after the key was rotated: the old secret comes back
// from the revision.
admin.mustStatus(200, "POST", "/api/v1/tsig-keys/"+k.ID+"/rotate", nil)
var rb service.RevisionDTO
viewer.mustStatus(403, "POST", "/api/v1/revisions/1/rollback", nil)
admin.mustStatus(201, "POST", "/api/v1/revisions/1/rollback", nil).json(t, &rb)
_, sealed2, err := e.st.Q().RevisionSource(ctx, rb.Number)
if err != nil || len(sealed2) == 0 {
t.Fatalf("rollback secrets %v", err)
}
var diff service.RevisionDiff
viewer.mustStatus(200, "GET", "/api/v1/revisions/2/diff", nil).json(t, &diff)
for _, f := range diff.Files {
if f.Target != "" {
t.Errorf("diff target %+v", f)
}
}
}