648 lines
30 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|