feat(api): agents, enrollment tokens, revisions, deployments and leases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1bdd877051
commit
92d1d9ec3d
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user