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

421 lines
17 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"errors"
"strconv"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// enroll registers an agent through the service (the gRPC layer is tested
// in package agentapi).
func (e *env) enroll(token, role string) string {
e.t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
e.t.Fatal(err)
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if err != nil {
e.t.Fatal(err)
}
res, err := e.svc.EnrollAgent(context.Background(), service.EnrollInput{Token: token, CSR: csr, Role: role,
Hostname: "host-" + role, AgentVersion: "1.0.0"})
if err != nil {
e.t.Fatalf("enroll: %v", err)
}
return res.AgentID
}
func (c *client) createToken(role string) service.EnrollmentTokenCreated {
c.e.t.Helper()
var out service.EnrollmentTokenCreated
c.mustStatus(201, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": role, "description": "for " + role}).json(c.e.t, &out)
return out
}
// configureDNS stores generator settings that allow DNS generation.
func (c *client) configureDNS() {
c.e.t.Helper()
var g service.GeneratorSettings
c.mustStatus(200, "GET", "/api/v1/settings/generator", nil).json(c.e.t, &g)
g.DNS.PrimaryNS = "ns1.office.test"
g.DNS.Hostmaster = "hostmaster@office.test"
g.DNS.NameServers = []service.GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}}
c.mustStatus(200, "PUT", "/api/v1/settings/generator", g)
}
type agentList = listResponse[service.AgentDTO]
func TestEnrollmentTokensAndAgents(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
operator := e.userClient(admin, "otto", "operator")
var agents agentList
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
if len(agents.Items) != 0 {
t.Fatalf("agents %+v", agents.Items)
}
viewer.mustStatus(403, "GET", "/api/v1/enrollment-tokens", nil)
operator.mustStatus(403, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns"})
tok := admin.createToken("dns")
id, secret, ok := strings.Cut(tok.Secret, ".")
if !ok || len(id) != 16 || id != tok.Token.ID || len(secret) != 43 {
t.Errorf("token format %q", tok.Secret)
}
if tok.CAFingerprint != e.pki.Fingerprint() || tok.ServerAddress != "ddi.example.test:8444" {
t.Errorf("token %+v", tok)
}
wantCmd := brand.AgentDNSBinary + " enroll --server ddi.example.test:8444 --token " + tok.Secret + " --ca-hash " + e.pki.Fingerprint()
if tok.Command != wantCmd {
t.Errorf("command %q, want %q", tok.Command, wantCmd)
}
if dhcp := admin.createToken("dhcp"); !strings.HasPrefix(dhcp.Command, brand.AgentDHCPBinary+" enroll ") {
t.Errorf("dhcp command %q", dhcp.Command)
}
r := admin.mustStatus(422, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns", "valid_hours": 721},
skipRequestValidation, "1")
if p := r.problem(t).Problems; len(p) != 1 || p[0].Field != "valid_hours" {
t.Errorf("problems %+v", p)
}
var tokens listResponse[service.EnrollmentTokenDTO]
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
if len(tokens.Items) != 2 || strings.Contains(string(admin.do("GET", "/api/v1/enrollment-tokens", nil).Body), secret) {
t.Fatalf("tokens %+v", tokens.Items)
}
// Enrollment through the service: wrong role, wrong secret, success,
// reuse.
ctx := context.Background()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dhcp"}); !errors.Is(err, service.ErrTokenRoleMismatch) {
t.Errorf("wrong role: %v", err)
}
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: id + ".wrong", CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
t.Errorf("wrong secret: %v", err)
}
agentID := e.enroll(tok.Secret, "dns")
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
t.Errorf("reused token: %v", err)
}
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
var used *service.EnrollmentTokenDTO
for i := range tokens.Items {
if tokens.Items[i].ID == id {
used = &tokens.Items[i]
}
}
if used == nil || used.UsedAt == nil || used.AgentID != agentID {
t.Errorf("used token %+v", used)
}
var ag service.AgentDTO
viewer.mustStatus(200, "GET", "/api/v1/agents/"+agentID, nil).json(t, &ag)
if ag.Role != "dns" || ag.Hostname != "host-dns" || ag.Connected || ag.Mode != "auto_pull" || ag.Health.State != "unknown" ||
ag.DesiredRevision != 0 || ag.CertExpiresAt.IsZero() {
t.Errorf("agent %+v", ag)
}
viewer.mustStatus(404, "GET", "/api/v1/agents/not-a-uuid", nil)
viewer.mustStatus(404, "GET", "/api/v1/agents/00000000-0000-4000-8000-000000000000", nil)
// Status from the agent.
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{Hostname: "ns1.office.test", AgentVersion: "1.0.1",
ServiceVersion: "BIND 9.20.3", Health: &service.AgentHealth{State: "ok", Checks: []service.HealthCheck{{Name: "rndc", State: "ok"}}}}); err != nil {
t.Fatal(err)
}
conn := e.hub.Attach(agentID, "dns", "auto_pull")
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
if len(agents.Items) != 1 || !agents.Items[0].Connected || agents.Items[0].ServiceVersion != "BIND 9.20.3" ||
agents.Items[0].Health.State != "ok" || len(agents.Items[0].Health.Checks) != 1 || agents.Items[0].LastSeenAt == nil {
t.Fatalf("agents %+v", agents.Items)
}
// Revocation closes the stream and is audited.
viewer.mustStatus(403, "POST", "/api/v1/agents/"+agentID+"/revoke", nil)
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil).json(t, &ag)
if ag.RevokedAt == nil || ag.Connected {
t.Errorf("revoked agent %+v", ag)
}
select {
case <-conn.Done():
default:
t.Error("stream of the revoked agent is still open")
}
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{}); !errors.Is(err, service.ErrAgentRevoked) {
t.Errorf("hello after revocation: %v", err)
}
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil) // idempotent
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=agent", nil).json(t, &audit)
if len(audit.Items) != 2 { // enroll, revoke
t.Errorf("agent audit %+v", audit.Items)
}
viewer.mustStatus(403, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(404, "GET", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(404, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/nonsense", nil)
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=enrollment_token", nil).json(t, &audit)
if len(audit.Items) != 3 { // two creates, one delete
t.Errorf("token audit %+v", audit.Items)
}
}
func TestRevisionsAndDeployments(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
operator := e.userClient(admin, "otto", "operator")
ctx := context.Background()
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{
"cidr": "192.0.2.0/24", "name": "Office", "gateway": "192.0.2.1", "domain": "office.test",
"dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}},
})
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.53", "type": "static", "hostname": "ns1"})
// Without DNS settings nothing is stored.
r := operator.mustStatus(422, "POST", "/api/v1/revisions", map[string]any{"comment": "first"})
if p := r.problem(t); p.Code != "dns_settings_missing" || p.Params["part"] != "dns" {
t.Errorf("problem %+v", p)
}
admin.configureDNS()
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
conn := e.hub.Attach(dnsAgent, "dns", "auto_pull")
viewer.mustStatus(403, "POST", "/api/v1/revisions", nil)
var rev service.RevisionDTO
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{"comment": "first"}).json(t, &rev)
if rev.Number != 1 || rev.Comment != "first" || rev.CreatedByName != "otto" || len(rev.DNS) == 0 || len(rev.DHCP) != 1 {
t.Fatalf("revision %+v", rev)
}
select {
case cmd := <-conn.Commands():
if cmd.GetApplyRevision().GetRevision() != 1 {
t.Errorf("command %v", cmd)
}
case <-time.After(time.Second):
t.Error("connected agent was not notified")
}
// Unchanged data: the latest revision with 200, no notification.
rev = service.RevisionDTO{}
operator.mustStatus(200, "POST", "/api/v1/revisions", nil).json(t, &rev)
if rev.Number != 1 || rev.Comment != "first" {
t.Errorf("unchanged %+v", rev)
}
select {
case cmd := <-conn.Commands():
t.Errorf("unexpected command %v", cmd)
default:
}
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.10", "type": "static", "hostname": "www"})
rev = service.RevisionDTO{}
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{}).json(t, &rev)
if rev.Number != 2 {
t.Fatalf("second revision %+v", rev)
}
zone := fileByPath(rev.DNS, "zones/db.office.test")
if zone == nil || !strings.Contains(zone.Content, "192.0.2.10") {
t.Errorf("zone %+v", zone)
}
var page service.RevisionPage
viewer.mustStatus(200, "GET", "/api/v1/revisions", nil).json(t, &page)
if len(page.Items) != 2 || page.Items[0].Number != 2 || page.NextBefore != nil || page.Items[0].DNSFiles != len(rev.DNS) || page.Items[0].DHCPFiles != 1 {
t.Fatalf("revisions %+v", page)
}
page = service.RevisionPage{}
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1", nil).json(t, &page)
if len(page.Items) != 1 || page.NextBefore == nil || *page.NextBefore != 2 {
t.Fatalf("first page %+v", page)
}
page = service.RevisionPage{}
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1&before=2", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Number != 1 || page.NextBefore != nil {
t.Fatalf("second page %+v", page)
}
rev = service.RevisionDTO{}
viewer.mustStatus(200, "GET", "/api/v1/revisions/1", nil).json(t, &rev)
if rev.Number != 1 || fileByPath(rev.DNS, "zones/db.office.test") == nil {
t.Errorf("revision 1 %+v", rev)
}
viewer.mustStatus(404, "GET", "/api/v1/revisions/99", nil)
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.create", nil).json(t, &audit)
if len(audit.Items) != 2 {
t.Errorf("revision audit %+v", audit.Items)
}
// Deployment reports.
started := e.now.Add(-time.Minute)
for _, rep := range []service.DeploymentReport{
{Revision: 1, Trigger: "push", Outcome: "succeeded", ActiveRevision: 1, StartedAt: started, FinishedAt: started.Add(time.Second)},
{Revision: 2, Trigger: "pull", Outcome: "rolled_back", Message: "SOA serial mismatch", ActiveRevision: 1, StartedAt: started},
} {
if err := e.svc.RecordDeployment(ctx, dnsAgent, rep); err != nil {
t.Fatal(err)
}
}
if err := e.svc.RecordDeployment(ctx, dnsAgent, service.DeploymentReport{Revision: 0, Trigger: "pull", Outcome: "succeeded"}); err == nil {
t.Error("revision 0 accepted")
}
var deps service.DeploymentPage
viewer.mustStatus(200, "GET", "/api/v1/deployments", nil).json(t, &deps)
if len(deps.Items) != 2 || deps.Items[0].Outcome != "rolled_back" || deps.Items[0].AgentHostname != "host-dns" || deps.NextBeforeID != nil {
t.Fatalf("deployments %+v", deps)
}
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1&revision=1&agent_id="+dnsAgent, nil).json(t, &deps)
if len(deps.Items) != 1 || deps.Items[0].Revision != 1 || deps.NextBeforeID != nil {
t.Errorf("filtered %+v", deps)
}
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1", nil).json(t, &deps)
if deps.NextBeforeID == nil {
t.Fatalf("paging %+v", deps)
}
before := *deps.NextBeforeID
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?before_id="+strconv.FormatInt(before, 10), nil).json(t, &deps)
if len(deps.Items) != 1 || deps.Items[0].Outcome != "succeeded" {
t.Errorf("before_id %+v", deps)
}
viewer.mustStatus(200, "GET", "/api/v1/deployments?agent_id=unknown", nil)
var ag service.AgentDTO
viewer.mustStatus(200, "GET", "/api/v1/agents/"+dnsAgent, nil).json(t, &ag)
if ag.AppliedRevision != 1 || ag.LastGoodRevision != 1 || ag.DesiredRevision != 2 || ag.LastDeployment == nil ||
ag.LastDeployment.Outcome != "rolled_back" {
t.Errorf("agent after deployments %+v", ag)
}
// Read-only mode blocks revisions but not the agents.
e.advance(31 * 24 * time.Hour)
admin.login("admin", "admin-password-123")
if p := admin.mustStatus(403, "POST", "/api/v1/revisions", nil).problem(t); p.Code != "license_read_only" {
t.Errorf("read-only %+v", p)
}
if err := e.svc.AgentHeartbeat(ctx, dnsAgent, service.HeartbeatInput{AppliedRevision: 2, LastGoodRevision: 2}); err != nil {
t.Errorf("heartbeat in read-only mode: %v", err)
}
}
func TestLeases(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"}).json(t, &netw)
ids, err := e.st.Q().SubnetIDs(ctx)
if err != nil {
t.Fatal(err)
}
subnet := ids[netw.ID]
dhcpAgent := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
exp := e.now.Add(time.Hour)
leases := []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:66", Hostname: "phone", State: "released", SubnetID: subnet},
{IP: "198.51.100.7", MAC: "00:11:22:33:44:77", State: "active", SubnetID: 9999},
{IP: "not-an-ip", State: "active"},
}
res, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, leases)
if err != nil || res.Acknowledged != 1 || res.Duplicate || res.Skipped != 1 {
t.Fatalf("batch %+v %v", res, err)
}
// A duplicate batch is dropped and acknowledged with the stored sequence.
res, err = e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, nil)
if err != nil || !res.Duplicate || res.Acknowledged != 1 {
t.Errorf("duplicate %+v %v", res, err)
}
if _, err := e.svc.StoreLeaseBatch(ctx, dnsAgent, 1, true, leases); !errors.Is(err, service.ErrNotDHCPAgent) {
t.Errorf("DNS agent leases: %v", err)
}
var page service.LeasePage
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 3 || page.NextAfter != nil || page.Items[0].NetworkID != netw.ID || page.Items[2].NetworkID != "" ||
page.Items[0].AgentID != dhcpAgent || page.Items[0].ExpiresAt == nil {
t.Fatalf("leases %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?network_id="+netw.ID+"&state=active", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Hostname != "laptop" {
t.Errorf("filtered %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?q=44:66", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Hostname != "phone" {
t.Errorf("search %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2", nil).json(t, &page)
if len(page.Items) != 2 || page.NextAfter == nil || *page.NextAfter != "192.0.2.101" {
t.Fatalf("first page %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2&after=192.0.2.101", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].IP.String() != "198.51.100.7" || page.NextAfter != nil {
t.Errorf("second page %+v", page)
}
viewer.mustStatus(422, "GET", "/api/v1/leases?after=nonsense", nil)
// Changes are merged; a full sync replaces the set.
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 2, false, []service.LeaseInput{
{IP: "192.0.2.101", MAC: "00:11:22:33:44:66", Hostname: "phone", State: "active", SubnetID: subnet},
}); err != nil {
t.Fatal(err)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?state=active", nil).json(t, &page)
if len(page.Items) != 3 {
t.Errorf("after delta %+v", page)
}
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 3, true, leases[:1]); err != nil {
t.Fatal(err)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 1 {
t.Errorf("after full sync %+v", page)
}
// Deleting the agent removes its leases.
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+dhcpAgent, nil)
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 0 {
t.Errorf("leases of deleted agent %+v", page)
}
}