0076. Approved machines in maintenance mode are provisioned over the insecure API and followed to running
Status: Accepted · Date: 2026-10-26 · Design section: Nodes (Approval), Configuration reconcile
Decided: an approved, never-converged machine that the bundle's identity cannot read is probed on the maintenance API; if it answers there and is the recorded machine and the render names an install disk, the render is applied once insecurely, and the node is then followed by requeue until MachineStatus says running.
Context
Architecture.md's Approval paragraphs say the controller "renders the config, applies it insecurely, and follows the
node through install, reboot and join". Nothing implemented it: the TalosMachine reconciler dials with the bundle's
os:admin identity, which a node in maintenance mode does not trust, so an approved worker sat at
Ready=False/CurrentConfigUnavailable (x509: certificate signed by unknown authority) forever.
Decision
- Detecting maintenance mode. Not by parsing the TLS error. The reconciler first tries the authenticated
CurrentConfig. Only if that fails,status.appliedConfigHashis empty (the machine never converged) and aMaintenanceClientis wired does it ask the insecure API for the node's identity. A node answers that only while it is in maintenance mode, so "the insecure read succeeded" is the signal. A machine that has converged once never takes this path: it has a configuration, and a node that has one refuses an insecure apply anyway. - Verify before applying. The identity read over the maintenance API must match
spec.identity: the UUID always, the serial and the MAC set wherever the spec records them (an operator-registered weak identity is compared on what it has). OtherwiseReady=False/IdentityMismatchand nothing is applied: someone else's machine is on that address. - Install disk. The rendered bytes must name where to install:
UnattendedInstallConfig(v1.14), ormachine.install.disk/diskSelector. OtherwiseInstallDiskUnknown, nothing applied. The installer image is not checked: Talos falls back to the running version's. - Apply.
MaintenanceClient.ApplyConfiguration(mode AUTO, whattalosctl apply-config --insecuresends) with its own 60 s timeout. A control plane first takes the cluster lock throughacquireDisruptiveLockwithcompletesTransition=true: a join is what turns an even count (a replacement in progress) back into an odd one, so the transitioning refusal must not apply to it. The shutdown block, the lock and the over-cap hold (ADR 0054) and the CA-rotation hold (ADR 0063) still do. The lock is held only for the apply call. - Follow. The RPC returning means "stored".
status.configStoredAtrecords it and is what stops any further insecure apply. After that each reconcile requeues every 15 s: if the node answers insecurely it is still in maintenance (AwaitingInstall); if neither API answers it isNodeRebooting; once the authenticated client reads it, the stage comes from MachineStatus (TalosClient.MachineStage):NodeInstalling,NodeRebooting,NodeStarting, and onrunningtheProvisionedcondition goes True (NodeJoined) and the ordinary configuration reconcile takes over.status.stageholds the last stage;installingright after the store andrebootingwhile unreachable are inferred,runningis observed. - Conditions.
Readycarries the same reason asProvisionedwhile following. Reasons:ConfigStored,AwaitingInstall,NodeInstalling,NodeRebooting,NodeStarting,NodeJoined,IdentityMismatch,InstallDiskUnknown; unchanged:CurrentConfigUnavailablewhen neither API answers before anything was stored. - Events.
ConfigAppliedwhen stored,NodeJoinedwhen the node runs. - Timeouts. Every Talos call has its own: 20 s for dial/identity/stage/current-config reads, 60 s for the apply. The install is followed by requeue, never a long call.
- Schema.
TalosMachine.statusgainsstageandconfigStoredAt(status only; no spec change).
Follow-ups from the first hardware run
- UI backend reads.
GetNode,GetNodeTalosDetailsand the log stream dial an approved machine with the bundle identity, which a node in maintenance mode does not trust.dialNodenow applies the same rule as the controller: for an approved machine with an emptystatus.appliedConfigHashit probes the authenticated connection (a hardware read) and, if that fails, reconnects over the maintenance API;GetNodeTalosDetails.maintenance_modeis true then. A machine that has converged never falls back. - Setting the install disk later. A machine approved without
spec.installsits atInstallDiskUnknown. The newMachineService.SetMachineInstallwrites onlyspec.install(as the user, with the usual GitOps-tracked refusal, pull request and override handling) and is refused for a pending machine or one that already has an applied configuration. The machine page offers it, with the approval form's disk picker, while that reason is set. - Log level. The failed authenticated read that hands a never-converged machine to the provisioning path is logged
at V(1) without a stack;
ERRORis kept for the case where nothing takes over.
Consequences and open questions
-
A node stuck in maintenance after the store is never re-applied automatically (conservative); recovery is to reset or re-register the machine. A deadline after which the reconciler would try again is left out.
-
The first control plane of a new cluster also needs
etcd bootstrap; this change joins nodes, it does not bootstrap. -
If the status write after a successful apply fails and the node still answers insecurely, the next reconcile applies the same bytes again; a node that already stored them rejects or ignores that.
-
A spec that records a UUID but whose node reports none (a weak hardware identity) is refused as a mismatch.
-
MachineStatus readability over the insecure API was not verified, so maintenance is inferred from the identity read succeeding rather than read as a stage.
-
Addendum:
spec.hostnameis rendered at the machine layer, over the patches: theHostnameConfigdocument withauto: offfrom the v1.14 contract,machine.network.hostnamebefore it; changing it stays reboot-required inpkg/fieldmeta. Dials useDialEndpoint()(ADR 0086).
Amendment 2026-11-05: a converged machine whose node fell back to maintenance mode is re-provisioned
Found on hardware (ADR 0123 run): an approved machine whose disk was wiped (talosctl reset --wipe-mode all) or replaced sat at Ready=False CurrentConfigUnavailable for good, because only a never-converged machine (status.appliedConfigHash empty) was probed on the maintenance API. The only way out was deleting it (allow-delete) and approving it again.
Decided: automatic, decided by the user on 2026-11-05. When the authenticated read of a converged machine fails, the controller probes the maintenance API like a first provisioning (with a 5 s bound, since a node that is merely down is probed every round). If the node answers there with the approved identity (identityMatches: UUID always, serial and MACs wherever the approval recorded them) the rendered configuration is sent again with the same install settings, and the machine is followed to running by the existing machinery.
- Identity. A mismatch keeps the old refusal (
IdentityMismatch, nothing sent): another machine is at that address. A node that answers neither API is reported as before (CurrentConfigUnavailable, thenNodeUnreachable). - Same gates as a join. The endpoint guard, the SecureBoot Required check (ADR 0123), the install-disk check, and for a control plane the per-machine cluster lock (
acquireDisruptiveLock,completesTransition=true) all apply. The over-cap and CA-rotation holds already run in Reconcile.ichor.maesh.dev/hold-apply: "true"(ADR 0103) stops it (ReprovisionBlocked); that is the only opt-out. - Once per wipe. Sending clears
status.appliedConfigHashandobservedInputsHashand recordsconfigStoredAt, so the machine is then a stored, never-converged one: the "stored is stored" rule above prevents a second send while the node is still in maintenance mode, and the node is followed (ConfigStored,AwaitingInstall,NodeInstalling...,NodeJoined). No new status field or annotation was needed. Because the reprovisioned state looks like a first provisioning, a node that is wiped again before it converged again is the documented limitation above (not re-applied). - Visible. Warning Event
NodeReinstalledand a Ready/Provisioned message beginning "Ichor re-installed this node", naming the hash that was sent. - Maintenance pin. A wiped node has a new maintenance certificate, so the pin recorded at the first provisioning (ADR 0108) would refuse every legitimate case. For a converged machine the first handshake is trusted again and its fingerprint recorded, unless the
trust-maintenance-fingerprintannotation holds one (then a different certificate givesMaintenanceIdentityChanged). This is the trust the first provisioning already had (a node's self-reported identity over an unauthenticated API); it relies on the separate provisioning network the design assumes. The residual is that an attacker on that network who answers on a down node's address with its UUID, serial and MACs now gets the configuration, CA keys included; the first provisioning had the same exposure at discovery. - Control planes and etcd. A wiped control plane is still listed as an etcd member, and a node joining under that name does not replace it. Before sending, under the cluster lock, the member (by
spec.hostname, elsestatus.nodeName) is looked up through another approved control plane (findEtcdSurvivor, as the removal does) and, if listed, removed withEtcdRemoveMemberByID(Warning EventStaleEtcdMemberRemoved). This is automatic because the node has proven it cannot be that member any more (it answers in maintenance mode with the approved identity, so its etcd data is gone), which is stronger evidence than the unreachable member ADR 0106 removes; etcd needs a quorum to accept the removal, and the cluster is no less fault tolerant without a member that cannot answer. If the removal fails the machine stays atStaleEtcdMemberwith a message namingichor node remove NAME(which removes the member and the machine, then the node is approved again); if the member name is unknown, etcd cannot be read, or there is no other control plane, it stays atReprovisionBlockedand nothing is sent. With no other control plane the cluster's etcd data is gone: the message points toichor restore --recover-cluster. - Rejected. An opt-in annotation or
spec.reprovision: Manual|Automaticwith aichor machine reprovisioncommand (the first design, with a one-shot request time instatus.lastReprovision): the user decided that reinstalling a node that is provably the approved machine and has no data left to lose needs no permission. A typed field can still be added later (append-only) if a per-machine "never" is wanted beyondhold-apply. - Not changed. Node reset and Kubernetes Node deletion (
ichor node resetleaves the Node object) are a separate change. The Kubernetes Node of a reinstalled machine is not touched here. - Tests (
internal/controller/talosmachine_reprovision_test.go): match sends once and follows to running; identity mismatch refuses; changed certificate under an accepted fingerprint refuses; neither API answering keeps the old report; hold-apply and a bad endpoint send nothing; control plane with a stale member (removed, then sent), with a member etcd will not drop, with etcd unreadable, with no other control plane, and with the lock held (nothing touched until it is free).
Amendment 2026-11-05: a discovery match cannot redirect a converged machine, and a re-provisioning holder keeps the lock
A security review of the automatic re-provision above found that its accepted risk was wider than written. The residual named is an attacker who answers at a down node's own address. But the maintenance probe dials DialEndpoint(), which prefers status.observedEndpoint, and discovery (ADR 0086) recorded any address that answered the insecure API with an approved machine's UUID, serial and MACs there, approved machines included. A twin on another address inside a discovery range therefore became the address of a healthy, converged machine; its next failed authenticated read (any hiccup) probed the twin, the pin was dropped for a converged machine, and the first handshake was trusted: the full control-plane configuration (CA keys) went to the twin, and for a control plane removeStaleEtcdMember could evict the live member. No wipe was needed.
Decided:
- Discovery no longer moves an approved machine.
Discoverrecords a new address (followsDiscoveredAddress) only for a machine that is not approved, or that is approved but has never been sent its configuration (status.configStoredAtandstatus.appliedConfigHashempty) and has a pinned maintenance certificate (status.maintenanceFingerprint): provisioning refuses a node with another certificate (MaintenanceIdentityChanged, ADR 0108), so following a redirect cannot hand the configuration to a twin, and a machine that moved between its approval and its first provisioning is still found. A converged machine's address changes only through its authenticated reads, the Kubernetes Node's InternalIP (followNodeAddress, needsspec.hostname) orspec.endpoint. - Re-provision therefore dials only addresses the machine is known at. For a converged machine
DialEndpoint()is nowspec.endpointor an address from the Node or from before its first configuration, never one a discovery match wrote afterwards. The probe is reached only after a failed authenticated read (a node that answers it is never provisioned), andremoveStaleEtcdMemberruns after the identity check at that address, so a twin elsewhere gets nothing and no member is removed. - Unchanged and still accepted. A wiped machine at its own known address trusts its first handshake, as above. The pre-existing exposure also stays: a pending machine's address is whatever discovery saw before approval.
- Cost. An approved, converged machine that is wiped and then comes back at a different address (a new lease) is no longer found by discovery. With
spec.hostnameset it is followed through its Node (which survives a wipe); otherwise the operator editsspec.endpoint, whichNodeUnreachablealready suggests. The tests do not cover a wiped machine that also moved. - L1, the cluster lock.
machineHolderStopped(lockholder.go) treated a machine withstatus.configStoredAtset, or Ready, as a holder that would never renew the lock.configStoredAtis never cleared, so a control plane holding the lock to re-provision (member removal, then the new configuration) looked finished and another actor could take the lock mid-step. A machine is now stopped when it is gone, or when itsconfigStoredAtis no older than the lease's acquire time (HeldError.Acquired, new inpkg/clusterlock; renewals do not move it). Ready no longer counts. - Tests.
discovery_test.go("an approved machine seen at another address": applied, stored, never configured without a pin, never configured with a pin);talosmachine_reprovision_test.go("with a twin answering the maintenance API at another address": no redirect, nothing sent, no etcd member removed, then a wipe at the real address re-provisions and removes the stale member);talosmachine_lock_test.go(a re-provisioning holder keeps the lock; a join that stored under the lock releases it);pkg/clusterlock(HeldError.Acquiredis the acquire time).