From 92d1d9ec3d014e8943c0e69ce7843fa2426fed9d Mon Sep 17 00:00:00 2001 From: "beug.k@beugsiud.de" Date: Tue, 13 Oct 2026 19:02:50 +0200 Subject: [PATCH] feat(api): agents, enrollment tokens, revisions, deployments and leases Co-Authored-By: Claude Opus 5.5 --- server/internal/httpapi/agents_test.go | 420 ++++++++++++++++++++++++ server/internal/httpapi/h_agents.go | 206 ++++++++++++ server/internal/httpapi/harness_test.go | 14 +- server/internal/httpapi/routes.go | 16 + 4 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 server/internal/httpapi/agents_test.go create mode 100644 server/internal/httpapi/h_agents.go diff --git a/server/internal/httpapi/agents_test.go b/server/internal/httpapi/agents_test.go new file mode 100644 index 0000000..c6f6ebe --- /dev/null +++ b/server/internal/httpapi/agents_test.go @@ -0,0 +1,420 @@ +// 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) + } +} diff --git a/server/internal/httpapi/h_agents.go b/server/internal/httpapi/h_agents.go new file mode 100644 index 0000000..30e3e9e --- /dev/null +++ b/server/internal/httpapi/h_agents.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: LicenseRef-DDI-Proprietary + +package httpapi + +import ( + "bytes" + "io" + "net/http" + "net/netip" + "net/url" + "strconv" + "strings" + + "git.beugsiud.de/beug.k/DDI/server/internal/service" + "git.beugsiud.de/beug.k/DDI/server/internal/store" +) + +// intParam parses an optional integer query parameter within [lo, hi] +// (0 if absent). +func intParam(q url.Values, name string, lo, hi int64) (int64, error) { + v := q.Get(name) + if v == "" { + return 0, nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < lo || n > hi { + return 0, errBadRequest("invalid_parameter", "invalid "+name) + } + return n, nil +} + +func (a *API) listAgents(w http.ResponseWriter, r *http.Request, _ *reqCtx) error { + agents, err := a.svc.ListAgents(r.Context()) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, listResponse[service.AgentDTO]{Items: agents}) +} + +func (a *API) getAgent(w http.ResponseWriter, r *http.Request, _ *reqCtx) error { + ag, err := a.svc.GetAgent(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, ag) +} + +func (a *API) revokeAgent(w http.ResponseWriter, r *http.Request, rc *reqCtx) error { + ag, err := a.svc.RevokeAgent(r.Context(), rc.Actor, r.PathValue("id")) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, ag) +} + +func (a *API) deleteAgent(w http.ResponseWriter, r *http.Request, rc *reqCtx) error { + if err := a.svc.DeleteAgent(r.Context(), rc.Actor, r.PathValue("id")); err != nil { + return err + } + return noContent(w) +} + +func (a *API) listEnrollmentTokens(w http.ResponseWriter, r *http.Request, _ *reqCtx) error { + tokens, err := a.svc.ListEnrollmentTokens(r.Context()) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, listResponse[service.EnrollmentTokenDTO]{Items: tokens}) +} + +type enrollmentTokenRequest struct { + Role string `json:"role"` + Description string `json:"description"` + ValidHours *int `json:"valid_hours"` +} + +func (a *API) createEnrollmentToken(w http.ResponseWriter, r *http.Request, rc *reqCtx) error { + var req enrollmentTokenRequest + if err := decode(w, r, &req); err != nil { + return err + } + out, err := a.svc.CreateEnrollmentToken(r.Context(), rc.Actor, service.TokenInput{ + Role: req.Role, Description: req.Description, ValidHours: req.ValidHours, + }) + if err != nil { + return err + } + return writeJSON(w, http.StatusCreated, out) +} + +func (a *API) deleteEnrollmentToken(w http.ResponseWriter, r *http.Request, rc *reqCtx) error { + if err := a.svc.DeleteEnrollmentToken(r.Context(), rc.Actor, r.PathValue("id")); err != nil { + return err + } + return noContent(w) +} + +func (a *API) listRevisions(w http.ResponseWriter, r *http.Request, _ *reqCtx) error { + q := r.URL.Query() + limit, err := intParam(q, "limit", 1, service.MaxRevisionPage) + if err != nil { + return err + } + before, err := intParam(q, "before", 1, 1<<62) + if err != nil { + return err + } + page, err := a.svc.ListRevisions(r.Context(), int(limit), before) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, page) +} + +type revisionRequest struct { + Comment string `json:"comment"` +} + +func (a *API) createRevision(w http.ResponseWriter, r *http.Request, rc *reqCtx) error { + var req revisionRequest + // The body is optional. + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody)) + if err != nil { + return errBadRequest("body_too_large", "request body too large") + } + if len(bytes.TrimSpace(body)) > 0 { + r.Body = io.NopCloser(bytes.NewReader(body)) + if err := decode(w, r, &req); err != nil { + return err + } + } + rev, created, err := a.svc.CreateRevision(r.Context(), rc.Actor, req.Comment) + if err != nil { + return err + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + return writeJSON(w, status, rev) +} + +func (a *API) getRevision(w http.ResponseWriter, r *http.Request, _ *reqCtx) error { + n, err := strconv.ParseInt(r.PathValue("number"), 10, 64) + if err != nil { + return service.ErrNotFound + } + rev, err := a.svc.GetRevision(r.Context(), n) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, rev) +} + +func (a *API) listDeployments(w http.ResponseWriter, r *http.Request, _ *reqCtx) error { + q := r.URL.Query() + f := service.DeploymentFilter{AgentID: q.Get("agent_id")} + var err error + if f.Revision, err = intParam(q, "revision", 1, store.MaxRevision); err != nil { + return err + } + limit, err := intParam(q, "limit", 1, service.MaxDeploymentPage) + if err != nil { + return err + } + f.Limit = int(limit) + if f.BeforeID, err = intParam(q, "before_id", 1, 1<<62); err != nil { + return err + } + page, err := a.svc.ListDeployments(r.Context(), f) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, page) +} + +var leaseStates = map[string]bool{"active": true, "expired": true, "released": true, "declined": true} + +func (a *API) listLeases(w http.ResponseWriter, r *http.Request, _ *reqCtx) error { + q := r.URL.Query() + f := service.LeaseFilter{NetworkID: q.Get("network_id"), State: q.Get("state"), Query: strings.TrimSpace(q.Get("q"))} + if f.State != "" && !leaseStates[f.State] { + return errBadRequest("invalid_parameter", "unknown lease state "+strconv.Quote(f.State)) + } + if len(f.Query) > 200 { + return errBadRequest("invalid_parameter", "q is too long") + } + limit, err := intParam(q, "limit", 1, store.MaxLeasePage) + if err != nil { + return err + } + f.Limit = int(limit) + if v := q.Get("after"); v != "" { + ip, err := netip.ParseAddr(v) + if err != nil { + return errBadRequest("invalid_parameter", "after must be an IP address") + } + ip = ip.Unmap() + f.After = &ip + } + page, err := a.svc.ListLeases(r.Context(), f) + if err != nil { + return err + } + return writeJSON(w, http.StatusOK, page) +} diff --git a/server/internal/httpapi/harness_test.go b/server/internal/httpapi/harness_test.go index cc93017..919db85 100644 --- a/server/internal/httpapi/harness_test.go +++ b/server/internal/httpapi/harness_test.go @@ -22,8 +22,10 @@ import ( "github.com/getkin/kin-openapi/routers/legacy" "git.beugsiud.de/beug.k/DDI/server/api" + "git.beugsiud.de/beug.k/DDI/server/internal/agenthub" "git.beugsiud.de/beug.k/DDI/server/internal/httpserver" "git.beugsiud.de/beug.k/DDI/server/internal/license" + "git.beugsiud.de/beug.k/DDI/server/internal/pki" "git.beugsiud.de/beug.k/DDI/server/internal/secrets" "git.beugsiud.de/beug.k/DDI/server/internal/service" "git.beugsiud.de/beug.k/DDI/server/internal/store" @@ -121,6 +123,8 @@ type env struct { st *store.Store lic *license.Manager now *time.Time + pki *pki.Authority + hub *agenthub.Hub } type stubProvider struct{} @@ -173,8 +177,14 @@ func newEnvWith(t *testing.T, adjust func(*Options)) *env { if err := lic.Load(context.Background()); err != nil { t.Fatal(err) } + authority, err := pki.LoadOrCreate(context.Background(), st, kr, log, nil) + if err != nil { + t.Fatal(err) + } + hub := agenthub.New() svc := service.New(service.Options{Store: st, Keyring: kr, License: lic, ProductName: "DDI-Suite", Logger: log, - Now: func() time.Time { return now }}) + Now: func() time.Time { return now }, PKI: authority, Hub: hub, AgentAPIAddress: "ddi.example.test:8444", + AgentServerNames: []string{"ddi.example.test"}}) opts := Options{Service: svc, ProductName: "DDI-Suite", Version: "test", Logger: log} srv := httptest.NewUnstartedServer(nil) if adjust != nil { @@ -185,7 +195,7 @@ func newEnvWith(t *testing.T, adjust func(*Options)) *env { srv.Config.Handler = httpserver.RequestID(contractHandler(t, a.Handler())) srv.Start() t.Cleanup(srv.Close) - return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now} + return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now, pki: authority, hub: hub} } // advance moves the shared clock (sessions, license). diff --git a/server/internal/httpapi/routes.go b/server/internal/httpapi/routes.go index 7d5de86..0148d92 100644 --- a/server/internal/httpapi/routes.go +++ b/server/internal/httpapi/routes.go @@ -59,6 +59,22 @@ func (a *API) Routes() []Route { // Generated configuration (read-only preview until agents arrive). perm(get, "/api/v1/config/preview", auth.PermIPAMRead, false, a.previewConfig), + // Agents and enrollment (ADR 0021). + perm(get, "/api/v1/agents", auth.PermIPAMRead, false, a.listAgents), + perm(get, "/api/v1/agents/{id}", auth.PermIPAMRead, false, a.getAgent), + perm(del, "/api/v1/agents/{id}", auth.PermSettingsAdm, true, a.deleteAgent), + perm(post, "/api/v1/agents/{id}/revoke", auth.PermSettingsAdm, true, a.revokeAgent), + perm(get, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, false, a.listEnrollmentTokens), + perm(post, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, true, a.createEnrollmentToken), + perm(del, "/api/v1/enrollment-tokens/{id}", auth.PermSettingsAdm, true, a.deleteEnrollmentToken), + + // Revisions, deployments and leases. + perm(get, "/api/v1/revisions", auth.PermIPAMRead, false, a.listRevisions), + perm(post, "/api/v1/revisions", auth.PermDeploy, true, a.createRevision), + perm(get, "/api/v1/revisions/{number}", auth.PermIPAMRead, false, a.getRevision), + perm(get, "/api/v1/deployments", auth.PermIPAMRead, false, a.listDeployments), + perm(get, "/api/v1/leases", auth.PermIPAMRead, false, a.listLeases), + // Networks. perm(get, "/api/v1/networks", auth.PermIPAMRead, false, a.listNetworks), perm(post, "/api/v1/networks", auth.PermIPAMWrite, true, a.createNetwork),