Networking
There is no ifupdown, no systemd-networkd, no NetworkManager, and no /etc/network/interfaces in Talos. Networking is a family of ~50 COSI controllers in machined, each a small reconciliation loop talking rtnetlink. Desired state flows downhill through layered config, a merge, and a kernel-apply step; observed kernel reality flows uphill as Status. Once you understand the address pipeline, you understand all of it — every subsystem is the same shape.
#No networkd — controllers all the way down
A conventional distro stitches networking together out of daemons and scripts: dhclient writes a lease, a hook fires, networkd reads a unit, an addr gets added imperatively. Talos throws all of that away. The networking subsystem is the single largest controller family on the box — roughly 50 controllers registered in v1alpha2_controller.go, producing about 40 resource types — and they live in internal/app/machined/pkg/controllers/network/ with their resources in pkg/machinery/resources/network/.
Each controller is the same tiny machine: watch input resources, compute desired output, write output resources. Nothing in the family runs ip or shells out. The kernel is reached only through a handful of Spec controllers that dial rtnetlink (github.com/jsimonetti/rtnetlink/v2) and, for link tuning, ethtool genetlink. The unifying idea is worth stating plainly:
Declarative desired state flows downhill to the kernel; observed kernel reality flows uphill as Status. There is no imperative "configure the interface" routine anywhere — convergence is emergent between a desired *Spec and an observed *Status that are deliberately never merged into one resource.
This is the same COSI engine the runtime chapter describes, applied to one of the messiest domains in any OS. The payoff is that an interface that flaps, a lease that renews, or a metadata server that was unreachable at boot all self-heal: the controllers simply re-run and reconcile.
#Two namespaces: config vs. merged
The trick that makes the family composable is that there are two COSI namespaces for network resources, declared in resources/network/network.go. The same Go type — say AddressSpec — lives in both, meaning two very different things depending on where you find it.
network-config
The ConfigNamespaceName. Holds unmerged candidate specs — one per (source × object). Five sources can each propose an address for the same link; all five coexist here because each candidate's ID embeds its ConfigLayer via network.LayeredID(layer, …), so they never clobber.
network
The NamespaceName. Holds the merged desired specs (one winner per object) and the observed statuses. The merged ID drops the layer suffix. This is the namespace the kernel-facing Spec controllers and downstream consumers actually read.
So the data flow is: many candidates land in network-config, a merge stage elects one winner into network, a spec stage pushes that winner to the kernel, and a wholly independent status stage observes the kernel and writes back into network. Keep the namespaces straight and the rest is mechanical.
#The Config → Merge → Spec → Kernel pipeline
This is the centerpiece. Every kernel-settable network object — address, route, link, hostname, resolver, time server, routing rule — passes through the same five-stage shape. The address pipeline is the cleanest worked example, so we trace it end to end.
Stage 1 — Config controllers translate each config source into candidate AddressSpec resources in network-config, each tagged with a ConfigLayer. AddressConfigController emits loopback defaults at ConfigDefault, parses the ip= kernel cmdline at ConfigCmdline, and reads the machine config at ConfigMachineConfiguration. Each candidate's ID is built with network.LayeredID(layer, network.AddressID(...)), so candidates from different layers coexist without overwriting one another. The layer ordering — lowest priority first — is the heart of the whole subsystem:
const (
ConfigDefault ConfigLayer = iota // default
ConfigCmdline // cmdline
ConfigPlatform // platform
ConfigOperator // operator
ConfigMachineConfiguration // configuration
)
So machine config beats a DHCP lease, which beats platform metadata, which beats the kernel cmdline, which beats the built-in defaults. That single enum decides every conflict in the subsystem.
Stage 2 — Merge controllers collapse all candidates for an object into one desired resource in network. They are all built from one reusable engine, GenericMergeController[T, E] in generic_merge.go. The address merge body is simply "if I already have a candidate at a higher layer for this key, keep it; otherwise take this one":
id := network.AddressID(address.TypedSpec().LinkName, address.TypedSpec().Address)
existing, ok := addresses[id]
if ok && existing.ConfigLayer > address.TypedSpec().ConfigLayer {
continue // skip; existing one is higher layer
}
addresses[id] = address.TypedSpec()
The desired-state spec carries its own provenance — note the ConfigLayer field riding along in the struct, so the merge can reason about precedence without any side table:
type AddressSpecSpec struct {
Address netip.Prefix `yaml:"address" protobuf:"1"`
LinkName string `yaml:"linkName" protobuf:"2"`
Family nethelpers.Family `yaml:"family" protobuf:"3"`
Scope nethelpers.Scope `yaml:"scope" protobuf:"4"`
Flags nethelpers.AddressFlags `yaml:"flags" protobuf:"5"`
Priority uint32 `yaml:"priority,omitempty" protobuf:"8"`
ConfigLayer ConfigLayer `yaml:"layer" protobuf:"7"`
}
Stage 3 — the Spec controller applies to the kernel. AddressSpecController takes an InputStrong on the merged AddressSpec, dials rtnetlink, and runs syncAddress to reconcile each desired address against conn.Address.List(). It is idempotent by construction: it compares scope/flags/priority, skips if the kernel already matches, deletes-then-re-adds on drift, and ignores EEXIST. It also subscribes to RTMGRP_LINK, so an address is re-applied the moment its link appears — you can "want" an address before the interface even exists.
Stage 4/5 — Status reflects kernel reality. AddressStatusController watches RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR, lists kernel addresses, and writes one AddressStatus per observed address. Crucially it has no inputs — it is a pure kernel-observer. Desired (AddressSpec) and observed (AddressStatus) are separate resources living side by side in the network namespace; nothing reconciles them into one. Convergence is emergent: the Spec controller keeps pushing the desired state, the Status controller keeps reporting reality, and they meet in the middle.
#The recurring quartet
The address pipeline is not special. Config / Merge / Spec / Status is a reusable shape, and every subsystem instantiates it. Learn it once and you can read route handling, link handling, hostname, resolver, and time-server handling without re-learning anything — only the resource type changes.
That last line on the diagram is real code, and it is the tell that the merge engine is generic. GenericMergeController derives its own controller name from the resource type it is merging — literally by replacing "Spec" with "MergeController" in the type string:
controllerName := strings.ReplaceAll(zeroE.ResourceDefinition().Type, "Spec", "MergeController")
#The controllers map
Roughly fifty controllers, grouped by the subsystem they serve. Once the quartet clicks, most of this table reads as "the same four things, again."
| Subsystem | Controllers |
|---|---|
| Addresses | AddressConfig · AddressMerge · AddressSpec · AddressStatus · AddressEvent |
| Links | LinkConfig · LinkMerge · LinkSpec (bonds/bridges/VLANs/wireguard/VRF, up/down) · LinkStatus · LinkAlias* (CEL renaming) · HardwareAddr |
| Routes | RouteConfig · RouteMerge · RouteSpec · RouteStatus |
| Routing rules | RoutingRuleConfig · RoutingRuleMerge · RoutingRuleSpec · RoutingRuleStatus |
| Hostname | HostnameConfig · HostnameMerge · HostnameSpec (calls sethostname) |
| Resolvers (DNS config) | ResolverConfig · ResolverMerge · ResolverSpec |
| Time servers (NTP) | TimeServerConfig · TimeServerMerge · TimeServerSpec |
| Operators | OperatorConfig · OperatorMerge · OperatorSpec · OperatorVIPConfig |
| Platform | PlatformConfig · PlatformConfigLoad · PlatformConfigStore · PlatformConfigApply |
| Host DNS | DNSResolveCache · DNSUpstream · HostDNSConfig · StaticHost |
| /etc files | EtcFile (renders /etc/hostname & /etc/resolv.conf) |
| Firewall | NfTablesChainConfig · NfTablesChain |
| Ethernet tuning | EthernetConfig · EthernetSpec (ethtool rings/channels/features/WoL) · EthernetStatus |
| Node addressing | NodeAddress · NodeAddressSortAlgorithm · Probe · Status (aggregate readiness) |
#Operators: DHCP4 / DHCP6 / VIP
So far every config source has been static. Operators are the dynamic ones: long-running goroutines that speak a live protocol (a DHCP lease, a VIP election) and continuously produce config-layer specs. They all implement a single Operator interface in controllers/network/operator/operator.go — note that it can emit candidates for every object type, not just addresses:
type Operator interface {
Run(ctx context.Context, notifyCh chan<- struct{})
Prefix() string
AddressSpecs() []network.AddressSpecSpec
RouteSpecs() []network.RouteSpecSpec
LinkSpecs() []network.LinkSpecSpec
HostnameSpecs() []network.HostnameSpecSpec
ResolverSpecs() []network.ResolverSpecSpec
TimeServerSpecs() []network.TimeServerSpecSpec
}
OperatorConfigController decides which operators should exist. Its most consequential behavior is why Talos "just gets an IP" on a fresh box: for every physical interface that has no explicit configuration, it schedules a default DHCP4 operator at ConfigDefault priority:
for linkStatus := range linkStatuses.All() {
if linkStatus.TypedSpec().Physical() {
if _, configured := configuredInterfaces[linkStatus.Metadata().ID()]; !configured {
specs = append(specs, network.OperatorSpecSpec{
Operator: network.OperatorDHCP4,
ConfigLayer: network.ConfigDefault,
})
OperatorSpecController then actually runs the goroutines (with runWithRestarts wrapping runWithPanicHandler), and on each notifyCh tick it pulls the operator's current specs and writes them into network-config at ConfigOperator priority. That is the elegant part: an operator is just another config source re-entering the same merge pipeline. A DHCP-leased address is a candidate at layer L3; a static address from machine config is a candidate at L4; the merge silently prefers the static one. Operator equality even ignores the config layer so the running goroutine isn't needlessly restarted when only its layer would differ:
func (spec OperatorSpecSpec) Equal(other OperatorSpecSpec) bool {
spec.ConfigLayer = other.ConfigLayer
return spec == other
}
#Platform integration
On a cloud instance, the network often comes from instance metadata rather than DHCP or local config. PlatformConfigController calls the active platform's metadata provider — AWS IMDS, GCP/Azure metadata, Equinix Metal — and produces a PlatformConfig resource. PlatformConfigLoadController and PlatformConfigStoreController cache that on the STATE partition, so networking can come up on a reboot even before the metadata endpoint is reachable. Finally PlatformConfigApplyController translates PlatformConfig into ordinary config-layer specs at ConfigPlatform priority — the same candidates everything else produces. Cloud-assigned external IPs surface through a synthetic external link so the rest of the pipeline can treat them uniformly.
#Host DNS & /etc files
Talos also ships an in-process caching resolver. HostDNSConfigController produces a HostDNSConfig (is it enabled? which addresses to listen on? should it resolve cluster member names?). DNSResolveCacheController runs the actual server — a dns.Manager on UDP and TCP — and can forward Kubernetes service DNS. Its upstreams come from DNSUpstreamController, which derives them from ResolverStatus (so the very resolver config the quartet produced feeds the cache). StaticHostController injects /etc/hosts-style entries. And because some software still reads files, EtcFileController renders /etc/resolv.conf and /etc/hostname from the merged resolver and hostname specs — the only place the declarative pipeline touches the classic Unix files, and even then as a one-way render.
#NodeAddress: what is this node's IP?
A box can have a dozen addresses — loopback, link-local, a VIP, a SideroLink tunnel, a cloud external IP. "Which one is the node's IP?" is a policy question, and Talos answers it in exactly one place: NodeAddressController. It consumes AddressStatus (observed reality, not raw config), filters out loopback/multicast/link-local, sorts the survivors, and emits several named sets — default, current, routed (which excludes external and SideroLink addresses), and accumulative (a monotonic union that never forgets an address it has seen). Certificates, the kubelet, and etcd all consume these curated sets rather than reaching for a raw AddressStatus — so the "what is my IP" decision is made once, consistently, for the whole machine.
Spec and Status stay separate, so an address can be "wanted" before its link exists. LayeredID stops candidates from clobbering. Operators are config sources, not appliers, so a static address silently wins over a DHCP one by priority alone. And restart resilience is baked in at every layer: Spec controllers reset their backoff on each success, operators retry forever with panic recovery, and rtnetlink link-events re-trigger reconciliation — so a flapping interface self-heals with no script in sight.
That is networking as a reconciliation graph: layered desired state in, kernel reality out, observed status flowing back, ~50 controllers all wearing the same four-part uniform. With the node's addresses now decided and stable, the next chapter takes the natural next step — standing up Kubernetes & etcd on top of them.