ADR 11 — API on by default
Status: accepted
Amends decision 11 and ADR 4.
Context
ADR 4 put the management API behind an explicit
api.enabled. Decision 11 states the rule: the API is node-local, off by
default, and a node that it has not claimed is in no cluster. ADR 4 argued for
the off-by-default half in one sentence, under Alternatives considered:
An always-on API with no way to switch it off. Fewer states, one less key, and a fleet where
cctlalways works. Rejected because a privileged daemon listening on every machine is a reasonable thing to veto, and because a node that has been running happily for a year should not acquire a new listening port by being upgraded.
That reasoning is about a node in service. It says nothing about a node that was just installed and that nobody has told what it is. This record is about that node.
The state the default produces
A machine installed from the published ISO with no cloud-init seed reaches a state that is coherent and supported, but unusable:
corium-bootstrapruns, finds no configuration, logsno corium configuration found, leaving node unconfigured, and writes no marker.corium-apidstarts,claimFromConfigurationgetssource.ErrNotFound, and the daemon exits 78. Nothing listens on 7443.- No account exists. Corium creates no default user (decision 7 leaves accounts
to cloud-init), and
PermitRootLoginisno. - The console prints
This node is not part of a clusterabove a login prompt that nobody can answer.
The machine is healthy and answers ping, but there is no way in. To recover it,
someone needs physical access: a CIDATA stick and a reboot, or a live system
and a write into /var/lib/cloud/seed/nocloud with the right SELinux label.
The project’s own answer to this is deploy/appliance/. It bakes
api.enabled: true into /usr/share/corium/config.yaml. That answer works and
is documented, but it is a derived image that nobody publishes. The recipe exists
because the published ISO cannot do what the recipe does. That is the definition
of a default in the wrong place.
What the ISO makes different
ADR 4 was written when every route onto a node carried configuration with it: cloud-init on a hypervisor, a metadata service on a cloud, a seed written by hand in a rescue system. In all three cases, “no configuration” means that somebody made a mistake. The installer ISO broke that assumption. It is the one artefact that installs a complete, running node but carries nothing about what the node is. That is what unattended means. And it is the artefact that bare metal is supposed to use.
So the question that ADR 4 answered is not the question that the ISO asks. ADR 4 asked whether to let a node in service grow a port. This record asks whether to make a node that was never configured reachable at all.
Decision
1. api: absent means the API runs
The default arm of Mode() flips. An absent api: block no longer resolves to
disabled. It resolves to a new mode, enrollable. In this mode, the daemon
serves and the enrolment route is open. The pairing code on the console
authenticates the route, exactly as in mode C today.
api.enabled: false keeps its meaning. It stays the way to have no daemon and no
port. It is now the only way, and that is the whole substance of this change.
2. enrollable serves, but does not hold the bootstrap
ADR 4 did not need this distinction, but this record does. Today maintenance
means two things at once: serve the enrolment route, and hold the bootstrap of
the node until an operator claims it. A flip of the default without a split of
these two meanings makes every existing single-line configuration hold its
bootstrap forever. Each such node waits for an operator who is never going to
come.
So the two meanings separate. HoldsBootstrap() stays exactly what it is: true
only for maintenance. An operator still asks for maintenance by writing
api.enabled: true with no CA, as
awaiting-config.yaml does. enrollable
serves the port and lets the bootstrap continue. A node with role: single and
no api: block behaves on first boot exactly as it does today. In addition, it
answers on 7443 while it gets there.
3. Enrolment closes when the node bootstraps
The central invariant (a rule that is always true) of ADR 4 is that a node is never valuable and unclaimed at the same time:
Letting an unenrolled node join the cluster and enrol later. […] it costs a node that is simultaneously valuable and unclaimed — running workloads, holding cluster credentials, and waiting to obey whoever reaches an unauthenticated port first. The pairing code was invented to defend that state. Deleting the state is better than defending it.
This record does not change that invariant, and the invariant sets the limits of
this change. The node serves the enrolment route only while
/var/lib/corium/bootstrapped is absent. A node that bootstrapped holds cluster
credentials and can run workloads. It refuses to be claimed, whatever its
configuration says. To take over such a node, an operator still needs
cctl reset, as today.
This is stricter than the current code, which gates enrolment on
store.Enrolled() alone. Today an operator can claim a node that bootstrapped and
later received api.enabled: true by hand. After this record, the operator
cannot. This is a deliberate tightening, and it is the price of a default that is
on.
4. The daemon runs where it can do something
The three rules above leave one state with no purpose: a node that bootstrapped from its own configuration, was never claimed, and names no operator CA. Rule 3 closes its enrolment route, and it has no CA to authenticate anyone with. Without a further rule, it listens on 7443 for the rest of its life and refuses every request. That is attack surface with no capability attached, and that is what the veto in ADR 4 was actually about.
So corium-apid serves when one of these conditions is true, and exits 78
otherwise:
- the node is still enrollable: it is not bootstrapped, and it is not
api.enabled: false, or - the node has an owner: it adopted an operator CA, by enrolment or from
api.operatorCA.
So every node is either claimable or already answerable, and no node carries a port that can do nothing. “On by default” describes what an operator must write. It does not promise that a socket exists on every machine in every state.
5. The pairing code stays the default, and insecure stays opt-in
Authentication does not change. A node with no configuration creates a pairing
code, prints it on the console, and requires it. api.insecure: true still
removes the code for a bench or a provisioning network, and still logs a warning.
The node still records the claim method as ClaimedOpenly, and cctl status
reports it.
This is the line between this record and deploy/appliance/. The appliance sets
insecure: true, and its README states the cost directly: the first client to
reach port 7443 owns the machine for the rest of its life. This record makes the
port the default, not the openness. An attacker on the network still
cannot claim a node if they cannot see its console.
6. A document that names no role still needs an explicit api:
An operator can omit role only when the API is on. This rule exists so that
validation refuses a configuration with a typo in role:, and does not produce a
node that waits forever. A flip of the default can silently remove this rule.
It does not remove it, because the two cases are different in kind. Validation
runs on a document, and the ISO case has no document at all. A document that
names no role and has no api: block is still refused, with the same error as
today. Writing a configuration that says nothing is a mistake. Shipping no
configuration is a choice, and this record serves that choice.
Consequences
A node installed from the ISO is enrollable. It prints a pairing code on its
console and waits, with no time limit, in no cluster, and with no change to
itself. cctl enroll <address> --code <code> --config node.yaml makes it a node.
That is the point of this record.
Some nodes in service acquire a listening port on upgrade. ADR 4 declined to pay this cost, and this record now pays part of it. A node that was claimed, or that names an operator CA, already had the daemon. A node that bootstrapped from its own configuration and was never claimed does not gain one, because rule 4 makes the daemon exit. What gains a port is the window between boot and bootstrap on every node. That window is about one minute, and its pairing code closes permanently when k0s comes up.
That window is new and it is real. The console requirement limits it. An attacker who can reach 7443 during that minute still needs the code, and the code is on a channel that the attacker does not have.
deploy/appliance/ loses most of its reason to exist. What remains of it is
insecure: true and awaitConfig: true, the zero-console posture. This record
deliberately does not make that posture the default. The recipe stays. Its README
gets a line that says the port is now default, and only the openness is not.
One more state to keep in mind. Mode() gets a fourth value, and “is the API
on?” becomes two questions, not one. That is the honest cost of rule 4. The
alternative was a dead socket on every node.
Reverting is a configuration change, not a rollback. A fleet that wants the
old posture writes api.enabled: false. There is no image-level switch. Adding
one creates a second authority over the same question.
Alternatives considered
Leave the default off and publish the appliance image. The smallest change:
no ADR, no code, one more artefact in the release. Rejected for two reasons. The
answer to “how do I install on bare metal” then depends on which of two images
you pulled. And the posture of the appliance is insecure: true, which is not a
posture to publish as the bare-metal path.
On by default only while the node is unbootstrapped. This option came first, and it is almost rule 4, reached from the other side. It differs on a node that was claimed and then bootstrapped. That node has an owner and must keep answering, so the rule must mention ownership anyway. It is clearer to state both halves than to state one and patch it.
On by default, with enrolment staying open until the node is claimed rather than until it bootstraps. Strictly more useful, because an operator can still adopt a node in service that nobody owns. Rejected because of the invariant of ADR 4, which is the load-bearing one in that record. This option recreates exactly the node that is valuable and unclaimed. It does so by default, not by an operator’s choice.
A timeout: listen for N minutes after boot, then stop. This makes the exposure window smaller. But it creates a state where a machine is neither configured nor reachable, and recovery again needs a physical visit. This record exists to remove that problem. For a configured node, bootstrap already limits the window. For an unconfigured node, the whole point is that it waits.
Make insecure the default too, matching the appliance. It makes bare metal
truly zero-touch, which is the one thing the pairing code costs. But it is the
model that ADR 4 argued against at length, and offered as an opt-in with the
argument still valid. Nothing here weakens that argument. A default that gives a
machine to whoever reaches it first is not a default.