@@ -21,11 +21,13 @@ import { type InstallDisk, InstallDiskSchema } from "@/gen/fleet/cryptos/v1/node
2121import { fleetClient } from "@/lib/fleet/client" ;
2222import { fleetMode } from "@/lib/fleet/mode" ;
2323
24- // Adopt-a-new-node (S10). The web drives two manager RPCs: PreviewAdoption
25- // (unary, trust-on-first-use fingerprint) and AdoptNode (server-streaming,
26- // live phase progress). The web never contacts the maintenance node directly
27- // and never sends any secret to an unpinned endpoint -- the manager holds the
28- // pin and orchestrates the whole apply -> install -> reboot -> ceremony flow.
24+ // Adopt-a-new-node (S10). The web drives three manager RPCs: PreviewAdoption
25+ // (unary, trust-on-first-use fingerprint), AdoptNode (server-streaming, live
26+ // phase progress) and ConfirmAdoptionFingerprint (unary, confirms the
27+ // installed node's certificate while the stream waits). The web never contacts
28+ // the maintenance node directly and never sends any secret to an unpinned
29+ // endpoint -- the manager holds the pin and orchestrates the whole apply ->
30+ // install -> reboot -> confirm -> ceremony flow.
2931
3032// AdoptionPreview is the maintenance node's presented identity the operator
3133// confirms before adoption proceeds.
@@ -36,14 +38,71 @@ export interface AdoptionPreview {
3638
3739// AdoptPhase is one streamed step of the orchestration. `done` marks the final
3840// message; the phase string is one of the manager's documented phases
39- // (applying-config, installing, awaiting-reboot, ceremony, established) or an
40- // error phase when a step fails.
41+ // (applying-config, installing, awaiting-reboot,
42+ // awaiting-fingerprint-confirmation, ceremony, established,
43+ // awaiting-certificate) or an error phase when a step fails. adoptionId names
44+ // the run; presentedCertSha256 is set only while the manager waits for the
45+ // installed node's fingerprint to be confirmed.
4146export interface AdoptPhase {
47+ adoptionId : string ;
4248 detail : string ;
4349 done : boolean ;
4450 phase : string ;
51+ presentedCertSha256 : string ;
4552}
4653
54+ export const AWAITING_FINGERPRINT = "awaiting-fingerprint-confirmation" ;
55+
56+ const normalizeFingerprint = ( fp : string ) : string => fp . replaceAll ( / [ \s : ] / g, "" ) . toLowerCase ( ) ;
57+
58+ // fingerprintsMatch compares two SHA-256 fingerprints the way the manager
59+ // does: case, colons and whitespace are ignored.
60+ export const fingerprintsMatch = ( a : string , b : string ) : boolean => {
61+ const na = normalizeFingerprint ( a ) ;
62+ return na !== "" && na === normalizeFingerprint ( b ) ;
63+ } ;
64+
65+ // formatFingerprint renders a fingerprint as colon-separated uppercase pairs,
66+ // easier to read against the node console than one long hex run.
67+ export const formatFingerprint = ( fp : string ) : string =>
68+ ( normalizeFingerprint ( fp ) . match ( / .{ 1 , 2 } / g) ?? [ ] ) . join ( ":" ) . toUpperCase ( ) ;
69+
70+ // The mock adoption's installed-node fingerprint, and the confirmations it is
71+ // waiting for, keyed by adoption ID.
72+ const MOCK_INSTALLED_SHA256 = "5f" . repeat ( 32 ) ;
73+ const mockWaiting = new Map < string , { reject : ( e : Error ) => void ; resolve : ( ) => void } > ( ) ;
74+ let mockAdoptions = 0 ;
75+
76+ // confirmAdoptionFingerprint tells the manager the operator checked the
77+ // installed node's fingerprint against its console, so the paused adoption
78+ // continues on the AdoptNode stream. A mismatch is refused by the manager and
79+ // the adoption fails; the error propagates to the caller.
80+ export const confirmAdoptionFingerprint = async (
81+ adoptionId : string ,
82+ certSha256 : string ,
83+ ) : Promise < void > => {
84+ if ( ! adoptionId || ! certSha256 ) {
85+ throw new Error ( "An adoption and a fingerprint are required." ) ;
86+ }
87+ if ( fleetMode ( ) === "mock" ) {
88+ const waiting = mockWaiting . get ( adoptionId ) ;
89+ if ( ! waiting ) {
90+ throw new Error ( `No adoption ${ adoptionId } is waiting for a fingerprint.` ) ;
91+ }
92+ mockWaiting . delete ( adoptionId ) ;
93+ if ( ! fingerprintsMatch ( certSha256 , MOCK_INSTALLED_SHA256 ) ) {
94+ const mismatch = new Error (
95+ "The confirmed fingerprint does not match the node's certificate." ,
96+ ) ;
97+ waiting . reject ( mismatch ) ;
98+ throw mismatch ;
99+ }
100+ waiting . resolve ( ) ;
101+ return ;
102+ }
103+ await fleetClient ( ) . confirmAdoptionFingerprint ( { adoptionId, certSha256 } ) ;
104+ } ;
105+
47106// previewAdoption fetches the maintenance node's certificate fingerprint and
48107// subject so the operator can confirm it (TOFU). `mock` returns a stable
49108// canned preview so the wizard is exercisable offline; live surfaces a
@@ -140,6 +199,7 @@ export async function* adoptNode(
140199 endpoint : string ,
141200 pinnedCertSha256 : string ,
142201 config : MachineConfig ,
202+ signal ?: AbortSignal ,
143203) : AsyncGenerator < AdoptPhase > {
144204 const trimmed = endpoint . trim ( ) ;
145205 if ( ! trimmed ) {
@@ -155,35 +215,65 @@ export async function* adoptNode(
155215 // script coherent with the role so the offline demo matches the live flow.
156216 const kind = config . role ?. kind ?? "" ;
157217 const subordinate = kind !== "" && kind !== "root" ;
218+ mockAdoptions += 1 ;
219+ const adoptionId = `mock-adoption-${ mockAdoptions } ` ;
220+ const step = ( phase : string , detail : string , done = false ) : AdoptPhase => ( {
221+ adoptionId,
222+ detail,
223+ done,
224+ phase,
225+ presentedCertSha256 : phase === AWAITING_FINGERPRINT ? MOCK_INSTALLED_SHA256 : "" ,
226+ } ) ;
158227 const common : AdoptPhase [ ] = [
159- { detail : "Applying the initial machine config." , done : false , phase : "applying-config" } ,
160- { detail : "Installing the CryptOS runtime." , done : false , phase : "installing" } ,
161- { detail : "Waiting for the node to reboot." , done : false , phase : "awaiting-reboot" } ,
228+ step ( "applying-config" , "Applying the initial machine config." ) ,
229+ step ( "installing" , "Installing the CryptOS runtime." ) ,
230+ step ( "awaiting-reboot" , "Waiting for the node to reboot." ) ,
231+ step ( AWAITING_FINGERPRINT , "Confirm the node's Mgmt SHA-256 from its console." ) ,
162232 ] ;
163233 const scripted : AdoptPhase [ ] = subordinate
164234 ? [
165235 ...common ,
166- {
167- detail : "Subordinate provisioned; awaiting a parent-signed certificate. ",
168- done : true ,
169- phase : "awaiting-certificate" ,
170- } ,
236+ step (
237+ " awaiting- certificate",
238+ "Subordinate provisioned; awaiting a parent-signed certificate." ,
239+ true ,
240+ ) ,
171241 ]
172242 : [
173243 ...common ,
174- { detail : "Running the enrollment ceremony." , done : false , phase : "ceremony" } ,
175- { detail : "Node established and linked to the fleet." , done : true , phase : "established" } ,
244+ step ( "ceremony" , "Running the enrollment ceremony." ) ,
245+ step ( "established" , "Node established and linked to the fleet." , true ) ,
176246 ] ;
177- for ( const step of scripted ) {
247+ for ( const s of scripted ) {
178248 // A short delay makes the mock progress visibly step through phases.
179249 await new Promise ( ( resolve ) => setTimeout ( resolve , 150 ) ) ;
180- yield step ;
250+ if ( s . phase === AWAITING_FINGERPRINT ) {
251+ const confirmed = new Promise < void > ( ( resolve , reject ) => {
252+ mockWaiting . set ( adoptionId , { reject, resolve } ) ;
253+ } ) ;
254+ // Keep a mismatch that nobody is awaiting yet from surfacing as an
255+ // unhandled rejection; it still rejects the await below.
256+ confirmed . catch ( ( ) => undefined ) ;
257+ yield s ;
258+ await confirmed ;
259+ continue ;
260+ }
261+ yield s ;
181262 }
182263 return ;
183264 }
184265
185- const stream = fleetClient ( ) . adoptNode ( { config, endpoint : trimmed , pinnedCertSha256 } ) ;
266+ const request = { config, endpoint : trimmed , pinnedCertSha256 } ;
267+ const stream = signal
268+ ? fleetClient ( ) . adoptNode ( request , { signal } )
269+ : fleetClient ( ) . adoptNode ( request ) ;
186270 for await ( const message of stream ) {
187- yield { detail : message . detail , done : message . done , phase : message . phase } ;
271+ yield {
272+ adoptionId : message . adoptionId ,
273+ detail : message . detail ,
274+ done : message . done ,
275+ phase : message . phase ,
276+ presentedCertSha256 : message . presentedCertSha256 ,
277+ } ;
188278 }
189279}
0 commit comments