diff --git a/internal/fleet/adopt.go b/internal/fleet/adopt.go index f2e4c40..1532cc5 100644 --- a/internal/fleet/adopt.go +++ b/internal/fleet/adopt.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "io" + "strings" "time" connect "connectrpc.com/connect" @@ -40,7 +41,11 @@ const ( phaseAwaitingReboot = "awaiting-reboot" phaseCeremony = "ceremony" phaseEstablished = "established" - phaseError = "error" + // phaseAwaitingCertificate is the terminal phase for a subordinate + // (intermediate/issuing) adoption: the node is provisioned and awaiting a + // parent-signed certificate, delivered separately by a SUBORDINATE enrollment. + phaseAwaitingCertificate = "awaiting-certificate" + phaseError = "error" ) // rebootWait bounds how long AdoptNode waits for a node to install, self-reboot, @@ -177,24 +182,30 @@ func (s *Service) runAdoption(ctx context.Context, msg *fleetv1.AdoptNodeRequest } defer func() { _ = conn.Close() }() - // Drive the first-boot ceremony, relaying each event as a ceremony phase. - if err := send(phaseCeremony, "starting first-boot ceremony", false); err != nil { - return err - } - yaml, err := marshalConfigYAML(cfg) - if err != nil { - return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: marshal config: %w", err)) - } - cstream, err := conn.StartCeremony(ctx, cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, yaml) - if err != nil { - return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: start ceremony: %w", err)) - } - complete, err := relayCeremony(cstream, send) - if err != nil { - return s.adoptFail(send, connect.CodeInternal, err) - } - if !complete { - return s.adoptFail(send, connect.CodeInternal, errors.New("fleet: ceremony stream ended before completing")) + // A root self-signs its CA via the first-boot ceremony. A subordinate + // (intermediate/issuing) node cannot: the node has already staged its own + // subordinate CSR on boot and is awaiting a parent-signed chain, which a + // SUBORDINATE enrollment delivers as a separate admin-approved step. Only a + // root reaches "established" during adoption. + if isRootRole(cfg) { + if err := send(phaseCeremony, "starting first-boot ceremony", false); err != nil { + return err + } + yaml, err := marshalConfigYAML(cfg) + if err != nil { + return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: marshal config: %w", err)) + } + cstream, err := conn.StartCeremony(ctx, cryptosv1.CeremonyKind_CEREMONY_KIND_FIRST_BOOT_ROOT, yaml) + if err != nil { + return s.adoptFail(send, connect.CodeInternal, fmt.Errorf("fleet: start ceremony: %w", err)) + } + complete, err := relayCeremony(cstream, send) + if err != nil { + return s.adoptFail(send, connect.CodeInternal, err) + } + if !complete { + return s.adoptFail(send, connect.CodeInternal, errors.New("fleet: ceremony stream ended before completing")) + } } // Register the node with the manager-held bootstrap admin credentials so @@ -211,6 +222,10 @@ func (s *Service) runAdoption(ctx context.Context, msg *fleetv1.AdoptNodeRequest TargetPath: "/nodes/" + adoptedNodeName(cfg, endpoint), }) + if !isRootRole(cfg) { + return send(phaseAwaitingCertificate, + "subordinate node provisioned and awaiting a parent-signed certificate (complete via a subordinate enrollment)", true) + } return send(phaseEstablished, "node adopted and established", true) } @@ -338,6 +353,15 @@ func adoptedNodeRole(cfg *cryptosv1.MachineConfig) string { return "node" } +// isRootRole reports whether the config declares a root CA node — the only role +// that self-signs via the first-boot ceremony. Intermediate and issuing nodes +// are subordinates, established later by a parent-signed enrollment. An omitted +// role is treated as root, matching the adopt wizard's default. +func isRootRole(cfg *cryptosv1.MachineConfig) bool { + kind := cfg.GetRole().GetKind() + return kind == "" || strings.EqualFold(kind, "root") +} + // marshalConfigYAML renders a MachineConfig for the node's StartCeremony, which // parses the bytes with a strict (KnownFields) YAML decoder. JSON is valid // YAML, so protojson output parses server-side — but the node's config keys are diff --git a/internal/fleet/adopt_test.go b/internal/fleet/adopt_test.go index df918eb..decc320 100644 --- a/internal/fleet/adopt_test.go +++ b/internal/fleet/adopt_test.go @@ -149,6 +149,49 @@ func TestRunAdoption_HappyPath_StreamsPhasesRegistersAndAudits(t *testing.T) { } } +func TestRunAdoption_Subordinate_AwaitsCertificate_NoCeremony(t *testing.T) { + adoptCredsBaseDir = t.TempDir() + st := memory.New(nil) + mconn := &fakeConn{applyConfigResp: &cryptosv1.ApplyConfigResponse{RequiresReboot: true, Generation: 1}} + // A subordinate node comes back running but has NO ceremony: it staged its + // own subordinate CSR on boot and awaits a parent-signed chain. The ceremony + // stream is intentionally absent — running it would be a bug. + running := &fakeConn{status: &cryptosv1.GetStatusResponse{}} + svc := New(st, dialFor(map[string]*fakeConn{"sub-node": running})).WithAdoption(nil, + func(endpoint, pin, clientCertPEM, clientKeyPEM string) (NodeConn, error) { return mconn, nil }) + + restore := setRebootTiming(5*time.Millisecond, 1*time.Millisecond, 1*time.Millisecond) + defer restore() + + cfg := &cryptosv1.MachineConfig{ + Metadata: &cryptosv1.Metadata{Name: "sub-node"}, + Role: &cryptosv1.Role{Kind: "intermediate"}, + } + sink := &collectSink{} + err := svc.runAdoption(context.Background(), &fleetv1.AdoptNodeRequest{ + Endpoint: "node:4443", PinnedCertSha256: "abc", Config: cfg, + }, sink.send) + if err != nil { + t.Fatalf("runAdoption subordinate error = %v", err) + } + + if !sink.done { + t.Error("subordinate adoption did not stream a terminal done phase") + } + if !containsPhase(sink.phases, phaseAwaitingCertificate) { + t.Errorf("phases = %v, want a terminal awaiting-certificate", sink.phases) + } + if containsPhase(sink.phases, phaseCeremony) || containsPhase(sink.phases, phaseEstablished) { + t.Errorf("phases = %v, a subordinate must not run the root ceremony or reach established", sink.phases) + } + if len(running.gotCeremonyYAML) != 0 { + t.Error("StartCeremony was called on a subordinate node") + } + if _, ok := st.Node("sub-node"); !ok { + t.Error("adopted subordinate was not registered in the inventory") + } +} + func TestRunAdoption_RebootNeverReturns_StreamsErrorPhase_NoHang(t *testing.T) { adoptCredsBaseDir = t.TempDir() st := memory.New(nil)