From 87f8864bbb0aac92769c1b575cdf92fca1b98f6a Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:52:33 -0400 Subject: [PATCH 1/2] feat(console): reach a maintenance node with no DHCP server With no DHCP server the kernel's ip=dhcp attempt gives up and leaves the interfaces down with no address, so a maintenance node had nothing to connect to and the console's Address line was empty. Maintenance and reprovision mode now run a link-local fallback: while no interface holds a reachable IPv4 address, it brings Ethernet links up and gives the first one with carrier an RFC 3927 address in 169.254.0.0/16, derived from its MAC so it is stable across boots. Only one interface gets one, so the node never holds two connected routes for the prefix. The console uses the new ReachableAddrs: the routable addresses when there are any, else the IPv4 link-local ones. IPv6 link-local addresses stay ignored, since the node configures no IPv6. The maintenance listener already binds 0.0.0.0, and the maintenance certificate names only localhost and is reached with --insecure, so no SAN change is needed. Closes #381 Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- docs/management-trust.md | 21 ++- internal/console/dashboard.go | 5 +- internal/console/mgmtcert.go | 28 +++- internal/console/mgmtcert_test.go | 63 +++++++ internal/init/linklocal.go | 173 +++++++++++++++++++ internal/init/linklocal_test.go | 221 +++++++++++++++++++++++++ internal/init/maintenance.go | 8 +- internal/init/netlink/netlink.go | 32 ++++ internal/init/netlink/netlink_linux.go | 12 ++ internal/init/netlink/netlink_other.go | 7 + internal/init/netlink/netlink_test.go | 59 +++++++ 11 files changed, 618 insertions(+), 11 deletions(-) create mode 100644 internal/init/linklocal.go create mode 100644 internal/init/linklocal_test.go diff --git a/docs/management-trust.md b/docs/management-trust.md index 1bd957e..6fb3dd7 100644 --- a/docs/management-trust.md +++ b/docs/management-trust.md @@ -93,6 +93,24 @@ certificate; reach it with `--insecure`. It has no configured address, only the one DHCP gave it, so its console shows that address under **Address** and the certificate's **Mgmt SHA-256**. +With no DHCP server on the link (a laptop cabled straight to the node, for +example), the node assigns itself an IPv4 link-local address (RFC 3927) in +169.254.0.0/16 on the first interface with a cable in, and the console shows +that address instead. The address is derived from the interface's MAC, so the +node comes back on the same one every boot. A routable address always takes +priority: the link-local one is only assigned, and only shown, when the node +has no other IPv4 address. Give the laptop a self-assigned 169.254.x.x address +too (most operating systems do this on their own when DHCP gets no answer) and +reach the node at the console's address: + +```sh +cryptosctl --endpoint 169.254.x.x:443 --insecure config apply -f node.yaml +``` + +The maintenance certificate names only `localhost`, whatever the address, so +`--insecure` is what reaches it on a DHCP address and on a link-local one alike; +the check is the console's **Mgmt SHA-256**, as below. + > [!CAUTION] > Compare the console's **Mgmt SHA-256** with the fingerprint the Fleet > Manager's adoption preview (or `openssl s_client`) shows before you adopt the @@ -120,7 +138,8 @@ same form as on the serving dashboard: | Intermediate or issuing CA waiting for its parent | **Awaiting parent certificate** | Fetch trust, then get the CSR signed | Only the maintenance screen also shows an **Address** line: the node's IPv4 -addresses, one per line. An installed node uses the address in its config. +addresses, one per line, or its self-assigned 169.254.x.x address when it has +no other. An installed node uses the address in its config. The two screens an adoption compares against say which check they are: under the fingerprint, the maintenance screen shows **check 1/2: compare with the diff --git a/internal/console/dashboard.go b/internal/console/dashboard.go index e696ed6..98d5757 100644 --- a/internal/console/dashboard.go +++ b/internal/console/dashboard.go @@ -50,8 +50,9 @@ func fleetLabel(s FleetState) string { // network or crypto identifiers, with two exceptions: MgmtFingerprint, the // SHA-256 of the public management certificate, which exists so an operator // can verify a pin against the node's own screen, and MgmtAddrs, which only -// the maintenance screen shows, because a node with no config has a DHCP -// address the operator cannot know in advance. +// the maintenance screen shows, because a node with no config has a DHCP (or, +// with no DHCP server, a self-assigned link-local) address the operator cannot +// know in advance. type View struct { RootCN string Issuer string diff --git a/internal/console/mgmtcert.go b/internal/console/mgmtcert.go index 93605a0..619bc00 100644 --- a/internal/console/mgmtcert.go +++ b/internal/console/mgmtcert.go @@ -92,10 +92,26 @@ func readManagementCert(path string) *x509.Certificate { return cert } -// ManagementAddrs returns, in order, the addresses in addrs an operator can -// reach the management listener on: IPv4 only, since the node configures no -// IPv6, and neither loopback nor link-local. +// ManagementAddrs returns, in order, the routable addresses in addrs an +// operator can reach the management listener on: IPv4 only, since the node +// configures no IPv6, and neither loopback nor link-local. func ManagementAddrs(addrs []net.Addr) []string { + return ipv4Addrs(addrs, func(ip net.IP) bool { return !ip.IsLoopback() && !ip.IsLinkLocalUnicast() }) +} + +// ReachableAddrs returns ManagementAddrs when there is at least one, else the +// IPv4 link-local (169.254.0.0/16) addresses in addrs. A maintenance node on +// a network with no DHCP server has only the link-local address it assigned +// itself, and a laptop cabled straight to it reaches the listener there. IPv6 +// link-local addresses are never returned: the node configures no IPv6. +func ReachableAddrs(addrs []net.Addr) []string { + if out := ManagementAddrs(addrs); len(out) > 0 { + return out + } + return ipv4Addrs(addrs, net.IP.IsLinkLocalUnicast) +} + +func ipv4Addrs(addrs []net.Addr, keep func(net.IP) bool) []string { var out []string for _, a := range addrs { var ip net.IP @@ -106,7 +122,7 @@ func ManagementAddrs(addrs []net.Addr) []string { ip = v.IP } ip4 := ip.To4() - if ip4 == nil || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() { + if ip4 == nil || !keep(ip4) { continue } out = append(out, ip4.String()) @@ -114,12 +130,12 @@ func ManagementAddrs(addrs []net.Addr) []string { return out } -// LocalManagementAddrs returns ManagementAddrs for this host's interfaces, or +// LocalManagementAddrs returns ReachableAddrs for this host's interfaces, or // nil when they cannot be read. func LocalManagementAddrs() []string { addrs, err := net.InterfaceAddrs() if err != nil { return nil } - return ManagementAddrs(addrs) + return ReachableAddrs(addrs) } diff --git a/internal/console/mgmtcert_test.go b/internal/console/mgmtcert_test.go index e8902e7..3956083 100644 --- a/internal/console/mgmtcert_test.go +++ b/internal/console/mgmtcert_test.go @@ -246,6 +246,69 @@ func TestManagementAddrsKeepsReachableIPv4(t *testing.T) { } } +func ipNet(cidr string) net.Addr { + ip, n, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + return &net.IPNet{IP: ip, Mask: n.Mask} +} + +// With no DHCP server the node's only IPv4 address is the link-local one it +// assigned itself, and that is what the console shows. A routable address +// always wins, loopback is never shown, and IPv6 (the node configures none, +// so any fe80:: address is the kernel's own) is ignored either way. +func TestReachableAddrsFallsBackToLinkLocal(t *testing.T) { + cases := []struct { + name string + addrs []net.Addr + want string + }{ + {"routable present", []net.Addr{ipNet("127.0.0.1/8"), ipNet("169.254.10.1/16"), ipNet("192.0.2.10/24"), ipNet("fe80::1/64")}, "192.0.2.10"}, + {"only link-local", []net.Addr{ipNet("127.0.0.1/8"), ipNet("169.254.10.1/16"), ipNet("fe80::1/64")}, "169.254.10.1"}, + {"two link-local in order", []net.Addr{ipNet("169.254.10.1/16"), &net.IPAddr{IP: net.ParseIP("169.254.20.2")}}, "169.254.10.1,169.254.20.2"}, + {"only loopback", []net.Addr{ipNet("127.0.0.1/8")}, ""}, + {"only IPv6 link-local", []net.Addr{ipNet("127.0.0.1/8"), ipNet("fe80::1/64")}, ""}, + {"none", nil, ""}, + } + for _, tc := range cases { + if got := strings.Join(console.ReachableAddrs(tc.addrs), ","); got != tc.want { + t.Errorf("%s: ReachableAddrs = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestRenderDashboardMaintenanceAddressLineForEachAddressCase(t *testing.T) { + fp := console.Fingerprint([]byte("maintenance cert")) + cases := []struct { + name string + addrs []net.Addr + want string + }{ + {"routable present", []net.Addr{ipNet("169.254.10.1/16"), ipNet("192.0.2.10/24")}, "Address 192.0.2.10"}, + {"only link-local", []net.Addr{ipNet("127.0.0.1/8"), ipNet("169.254.10.1/16"), ipNet("fe80::1/64")}, "Address 169.254.10.1"}, + {"only loopback", []net.Addr{ipNet("127.0.0.1/8")}, ""}, + {"only IPv6 link-local", []net.Addr{ipNet("fe80::1/64")}, ""}, + } + for _, tc := range cases { + v := maintenanceView(fp, console.ReachableAddrs(tc.addrs)...) + var got string + for _, ln := range screenLines(console.RenderDashboard(v, 64, 24)) { + ln = strings.Join(strings.Fields(strings.Trim(ln, "| ")), " ") + if strings.HasPrefix(ln, "Address") { + got = ln + } + } + if got != tc.want { + t.Errorf("%s: Address line = %q, want %q", tc.name, got, tc.want) + } + compact := stripSGR(console.RenderDashboard(v, 30, 10)) + if hasIP := strings.Contains(compact, "IP "); hasIP != (tc.want != "") { + t.Errorf("%s: compact IP line shown = %v, want %v:\n%s", tc.name, hasIP, tc.want != "", compact) + } + } +} + // awaitingCeremonyView is an installed Root node that has not run its // ceremony yet. func awaitingCeremonyView(fp string) console.View { diff --git a/internal/init/linklocal.go b/internal/init/linklocal.go new file mode 100644 index 0000000..f37b1f8 --- /dev/null +++ b/internal/init/linklocal.go @@ -0,0 +1,173 @@ +package init + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "log" + "net" + "net/netip" + "time" + + "github.com/CryptOS-PKI/cryptos-node/internal/console" + "github.com/CryptOS-PKI/cryptos-node/internal/init/netlink" +) + +// linkLocalInterval is how often a maintenance node checks whether it needs a +// self-assigned address. A laptop can be cabled in at any time after boot. +const linkLocalInterval = 2 * time.Second + +// nicState is the part of an interface the link-local fallback reads. +type nicState struct { + Name string + MAC net.HardwareAddr + Up bool + Carrier bool + Loopback bool + Addrs []net.Addr +} + +// linkLocalDeps are the host operations the fallback needs, injectable so the +// selection logic is tested without touching the host's interfaces. +type linkLocalDeps struct { + nics func() ([]nicState, error) + linkUp func(name string) error + addAddr func(name string, p netip.Prefix) error +} + +// linkLocalAddr returns the IPv4 link-local address (RFC 3927) a node with +// this MAC assigns itself: drawn from 169.254.1.0 to 169.254.254.255 and seeded +// from the MAC, as RFC 3927 section 2.1 recommends, so a node comes back on the +// same address every boot and the operator can keep using it. +func linkLocalAddr(mac net.HardwareAddr) netip.Addr { + sum := sha256.Sum256(mac) + n := binary.BigEndian.Uint16(sum[:2]) % (254 * 256) + return netip.AddrFrom4([4]byte{169, 254, byte(1 + n/256), byte(n % 256)}) +} + +// step makes one pass. When no interface has a reachable IPv4 address (the +// kernel's ip=dhcp got no answer, and nothing has been assigned yet), it brings +// down Ethernet links up so their carrier shows, and gives the first one with +// carrier its link-local address in 169.254.0.0/16. Only one interface gets one, +// so the node never holds two connected routes for the same prefix. It returns +// the prefix it assigned and the interface, or the zero prefix when it did +// nothing. +// +// No ARP probe is sent before claiming the address: the case this serves is a +// laptop cabled straight to the node, where the only other host on the link +// picks its own address the same way, and a clash is a 1 in 65024 chance that +// the operator sees at once and fixes by replugging. +func (d linkLocalDeps) step() (netip.Prefix, string, error) { + nics, err := d.nics() + if err != nil { + return netip.Prefix{}, "", err + } + var all []net.Addr + for _, n := range nics { + all = append(all, n.Addrs...) + } + if len(console.ReachableAddrs(all)) > 0 { + return netip.Prefix{}, "", nil + } + var errs []error + for _, n := range nics { + if n.Loopback || len(n.MAC) != 6 { + continue + } + if !n.Up { + if err := d.linkUp(n.Name); err != nil { + errs = append(errs, err) + } + continue + } + if !n.Carrier { + continue + } + p := netip.PrefixFrom(linkLocalAddr(n.MAC), 16) + if err := d.addAddr(n.Name, p); err != nil { + return netip.Prefix{}, "", errors.Join(append(errs, err)...) + } + return p, n.Name, errors.Join(errs...) + } + return netip.Prefix{}, "", errors.Join(errs...) +} + +// runLinkLocalFallback runs step every interval until ctx ends, so a maintenance +// node with no DHCP server stays reachable on a self-assigned address. The +// console picks the address up on its next refresh (console.ReachableAddrs). +func runLinkLocalFallback(ctx context.Context, d linkLocalDeps, every time.Duration) { + t := time.NewTicker(every) + defer t.Stop() + var lastErr string + for { + p, nic, err := d.step() + if p.IsValid() { + log.Printf("maintenance: no DHCP address; assigned link-local %s to %s", p, nic) + } + msg := "" + if err != nil { + msg = err.Error() + } + if msg != "" && msg != lastErr { + log.Printf("maintenance: link-local fallback: %v", err) + } + lastErr = msg + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// startLinkLocalFallback starts runLinkLocalFallback on this host's interfaces +// and returns the func that stops it. +func startLinkLocalFallback(ctx context.Context) func() { + ctx, cancel := context.WithCancel(ctx) + go runLinkLocalFallback(ctx, linkLocalDeps{ + nics: localNICs, + linkUp: netlink.SetLinkUp, + addAddr: netlink.AddAddress, + }, linkLocalInterval) + return cancel +} + +func localNICs() ([]nicState, error) { + ifs, err := net.Interfaces() + if err != nil { + return nil, err + } + out := make([]nicState, 0, len(ifs)) + for _, ifc := range ifs { + addrs, err := ifc.Addrs() + if err != nil { + return nil, err + } + out = append(out, nicState{ + Name: ifc.Name, + MAC: ifc.HardwareAddr, + Up: ifc.Flags&net.FlagUp != 0, + Carrier: ifc.Flags&net.FlagRunning != 0, + Loopback: ifc.Flags&net.FlagLoopback != 0, + Addrs: addrs, + }) + } + return out, nil +} diff --git a/internal/init/linklocal_test.go b/internal/init/linklocal_test.go new file mode 100644 index 0000000..715b997 --- /dev/null +++ b/internal/init/linklocal_test.go @@ -0,0 +1,221 @@ +package init + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "errors" + "net" + "net/netip" + "testing" + "time" +) + +func mustMAC(t *testing.T, s string) net.HardwareAddr { + t.Helper() + mac, err := net.ParseMAC(s) + if err != nil { + t.Fatal(err) + } + return mac +} + +func cidr(s string) net.Addr { + ip, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return &net.IPNet{IP: ip, Mask: n.Mask} +} + +// RFC 3927 2.1: the address is drawn from 169.254.1.0 to 169.254.254.255 and +// seeded from the MAC, so a node comes back on the same address every boot. +func TestLinkLocalAddrIsStableAndInRange(t *testing.T) { + low := netip.MustParseAddr("169.254.1.0") + high := netip.MustParseAddr("169.254.254.255") + seen := map[netip.Addr]bool{} + for i := 0; i < 512; i++ { + mac := net.HardwareAddr{0x52, 0x54, 0x00, byte(i >> 8), byte(i), 0x01} + a := linkLocalAddr(mac) + if a != linkLocalAddr(mac) { + t.Fatalf("%s: address not stable", mac) + } + if a.Less(low) || high.Less(a) { + t.Fatalf("%s: %s outside 169.254.1.0-169.254.254.255", mac, a) + } + seen[a] = true + } + if len(seen) < 400 { + t.Fatalf("512 MACs gave only %d distinct addresses", len(seen)) + } +} + +type fakeNICs struct { + nics []nicState + nicErr error + up []string + added map[string]netip.Prefix + addErr error +} + +func (f *fakeNICs) deps() linkLocalDeps { + return linkLocalDeps{ + nics: func() ([]nicState, error) { return f.nics, f.nicErr }, + linkUp: func(name string) error { + f.up = append(f.up, name) + return nil + }, + addAddr: func(name string, p netip.Prefix) error { + if f.addErr != nil { + return f.addErr + } + if f.added == nil { + f.added = map[string]netip.Prefix{} + } + f.added[name] = p + return nil + }, + } +} + +func lo() nicState { + return nicState{Name: "lo", Up: true, Carrier: true, Loopback: true, Addrs: []net.Addr{cidr("127.0.0.1/8")}} +} + +func TestLinkLocalStepLeavesARoutableAddressAlone(t *testing.T) { + mac := mustMAC(t, "52:54:00:12:34:56") + f := &fakeNICs{nics: []nicState{lo(), {Name: "eth0", MAC: mac, Up: true, Carrier: true, Addrs: []net.Addr{cidr("192.0.2.10/24")}}}} + if _, _, err := f.deps().step(); err != nil { + t.Fatal(err) + } + if len(f.up) != 0 || len(f.added) != 0 { + t.Fatalf("touched a node with a DHCP address: up=%v added=%v", f.up, f.added) + } +} + +func TestLinkLocalStepAssignsOnceOnTheFirstLinkWithCarrier(t *testing.T) { + mac0 := mustMAC(t, "52:54:00:00:00:01") + mac1 := mustMAC(t, "52:54:00:00:00:02") + mac2 := mustMAC(t, "52:54:00:00:00:03") + f := &fakeNICs{nics: []nicState{ + lo(), + {Name: "eth0", MAC: mac0, Up: true, Carrier: false}, + {Name: "eth1", MAC: mac1, Up: true, Carrier: true, Addrs: []net.Addr{cidr("fe80::5054:ff:fe00:2/64")}}, + {Name: "eth2", MAC: mac2, Up: true, Carrier: true}, + }} + p, nic, err := f.deps().step() + if err != nil { + t.Fatal(err) + } + want := netip.PrefixFrom(linkLocalAddr(mac1), 16) + if nic != "eth1" || p != want { + t.Fatalf("step = %s on %q, want %s on eth1", p, nic, want) + } + if len(f.added) != 1 || f.added["eth1"] != want { + t.Fatalf("added = %v, want only eth1 %s", f.added, want) + } +} + +func TestLinkLocalStepDoesNothingWhenOneIsAlreadyAssigned(t *testing.T) { + mac := mustMAC(t, "52:54:00:00:00:01") + f := &fakeNICs{nics: []nicState{ + lo(), + {Name: "eth0", MAC: mac, Up: true, Carrier: true, Addrs: []net.Addr{cidr("169.254.7.7/16")}}, + {Name: "eth1", MAC: mustMAC(t, "52:54:00:00:00:02"), Up: true, Carrier: true}, + }} + p, _, err := f.deps().step() + if err != nil || p.IsValid() || len(f.added) != 0 { + t.Fatalf("step = %s, %v, added %v; want nothing", p, err, f.added) + } +} + +// The kernel's failed ip=dhcp attempt leaves its interfaces down, so carrier +// can't be seen until something brings them up. +func TestLinkLocalStepBringsDownLinksUp(t *testing.T) { + f := &fakeNICs{nics: []nicState{ + lo(), + {Name: "eth0", MAC: mustMAC(t, "52:54:00:00:00:01")}, + {Name: "eth1", MAC: mustMAC(t, "52:54:00:00:00:02")}, + {Name: "sit0", MAC: net.HardwareAddr{0, 0, 0, 0}}, + }} + if _, _, err := f.deps().step(); err != nil { + t.Fatal(err) + } + if len(f.up) != 2 || f.up[0] != "eth0" || f.up[1] != "eth1" || len(f.added) != 0 { + t.Fatalf("up = %v added = %v, want eth0 and eth1 up and nothing added", f.up, f.added) + } +} + +func TestLinkLocalStepOnlyLoopbackDoesNothing(t *testing.T) { + f := &fakeNICs{nics: []nicState{lo()}} + p, _, err := f.deps().step() + if err != nil || p.IsValid() || len(f.up) != 0 { + t.Fatalf("step = %s, %v, up %v; want nothing", p, err, f.up) + } +} + +func TestLinkLocalStepErrors(t *testing.T) { + f := &fakeNICs{nicErr: errors.New("no netlink")} + if _, _, err := f.deps().step(); err == nil { + t.Error("interface read error should propagate") + } + g := &fakeNICs{addErr: errors.New("EPERM"), nics: []nicState{{Name: "eth0", MAC: mustMAC(t, "52:54:00:00:00:01"), Up: true, Carrier: true}}} + if _, _, err := g.deps().step(); err == nil { + t.Error("add address error should propagate") + } +} + +// The fallback keeps checking, so a cable plugged in after boot still gets an +// address, and it stops when maintenance ends. +func TestRunLinkLocalFallbackAssignsWhenCarrierArrivesAndStops(t *testing.T) { + mac := mustMAC(t, "52:54:00:00:00:01") + f := &fakeNICs{nics: []nicState{lo(), {Name: "eth0", MAC: mac, Up: true}}} + d := f.deps() + calls := 0 + nics := d.nics + carrier := make(chan struct{}) + d.nics = func() ([]nicState, error) { + calls++ + if calls == 2 { + f.nics[1].Carrier = true + } + if calls == 3 { + close(carrier) + } + return nics() + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + runLinkLocalFallback(ctx, d, time.Millisecond) + close(done) + }() + select { + case <-carrier: + case <-time.After(5 * time.Second): + t.Fatal("fallback stopped checking") + } + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("fallback did not stop on cancel") + } + if f.added["eth0"] != netip.PrefixFrom(linkLocalAddr(mac), 16) { + t.Fatalf("added = %v, want eth0 link-local", f.added) + } +} diff --git a/internal/init/maintenance.go b/internal/init/maintenance.go index cd70656..6c533df 100644 --- a/internal/init/maintenance.go +++ b/internal/init/maintenance.go @@ -72,8 +72,10 @@ func startConsoleSocket(ctx context.Context) func() { } // runMaintenance brings up the limited maintenance service. Loopback is up and -// the kernel has configured the primary interface via ip=dhcp, so we only serve -// the management API with a self-signed maintenance cert and NO client +// the kernel has configured the primary interface via ip=dhcp; when no DHCP +// server answered, startLinkLocalFallback gives an interface a self-assigned +// 169.254.0.0/16 address instead. We only serve the management API, on every +// address (0.0.0.0), with a self-signed maintenance cert and NO client // verification (Talos --insecure). No TPM, LUKS, etcd, or ceremony. Parks until // a shutdown signal or a successful install (which triggers reboot), then // returns so PID 1 reboots. @@ -81,6 +83,7 @@ func runMaintenance(ctx context.Context) error { if err := netlink.BringUpLoopback(); err != nil { return err } + defer startLinkLocalFallback(ctx)() serverCert, removeCert, err := newMaintenanceCert(console.ManagementCertPath) if err != nil { return err @@ -145,6 +148,7 @@ func runReprovisionMaintenance(ctx context.Context, cfgStore *config.FileStore, if err := netlink.BringUpLoopback(); err != nil { return err } + defer startLinkLocalFallback(ctx)() serverCert, removeCert, err := newMaintenanceCert(console.ManagementCertPath) if err != nil { return err diff --git a/internal/init/netlink/netlink.go b/internal/init/netlink/netlink.go index 92bd7e6..0afa649 100644 --- a/internal/init/netlink/netlink.go +++ b/internal/init/netlink/netlink.go @@ -56,6 +56,38 @@ func bringUpLoopback(ifIndex indexFunc, send sendFunc) error { return nil } +// setLinkUp sets IFF_UP on the named interface. +func setLinkUp(name string, ifIndex indexFunc, send sendFunc) error { + idx, err := ifIndex(name) + if err != nil { + return fmt.Errorf("netlink: %s index: %w", name, err) + } + if err := send(buildLinkUpRequest(1, idx)); err != nil { + return fmt.Errorf("netlink: set %s up: %w", name, err) + } + return nil +} + +// addAddress brings the named interface up and adds the IPv4 prefix p to it. +// Unlike configure it flushes nothing and installs no route: the kernel adds +// the connected route for p itself. +func addAddress(name string, p netip.Prefix, ifIndex indexFunc, send sendFunc) error { + if !p.IsValid() || !p.Addr().Is4() { + return fmt.Errorf("netlink: add address: %q needs a valid IPv4 address/prefix", name) + } + idx, err := ifIndex(name) + if err != nil { + return fmt.Errorf("netlink: %s index: %w", name, err) + } + if err := send(buildLinkUpRequest(1, idx)); err != nil { + return fmt.Errorf("netlink: set %s up: %w", name, err) + } + if err := send(buildAddrRequest(2, idx, p)); err != nil { + return fmt.Errorf("netlink: add %s to %s: %w", p, name, err) + } + return nil +} + // configure brings the named interface up, flushes any existing IPv4 // addresses (e.g. a kernel ip=dhcp address on a different subnet), // assigns the static address, and (if set) installs a default route via diff --git a/internal/init/netlink/netlink_linux.go b/internal/init/netlink/netlink_linux.go index f7ce42d..a5f8a33 100644 --- a/internal/init/netlink/netlink_linux.go +++ b/internal/init/netlink/netlink_linux.go @@ -21,6 +21,7 @@ limitations under the License. import ( "fmt" "net" + "net/netip" "syscall" "golang.org/x/sys/unix" @@ -47,6 +48,17 @@ func ConfigureInterface(cfg Config) error { return configure(cfg, interfaceIndex, sendAck, sendDump) } +// SetLinkUp sets the named interface up. +func SetLinkUp(name string) error { + return setLinkUp(name, interfaceIndex, sendAck) +} + +// AddAddress brings the named interface up and adds the IPv4 prefix p to it, +// leaving any address it already has in place. +func AddAddress(name string, p netip.Prefix) error { + return addAddress(name, p, interfaceIndex, sendAck) +} + // sendDump opens an AF_NETLINK route socket, sends a dump request // (NLM_F_DUMP), and collects all response messages until NLMSG_DONE. // Each Recvfrom call may carry multiple netlink messages; they are split diff --git a/internal/init/netlink/netlink_other.go b/internal/init/netlink/netlink_other.go index ef54dc5..3df82c9 100644 --- a/internal/init/netlink/netlink_other.go +++ b/internal/init/netlink/netlink_other.go @@ -20,6 +20,7 @@ limitations under the License. import ( "errors" + "net/netip" "runtime" ) @@ -33,3 +34,9 @@ func BringUpLoopback() error { return errUnsupported } // ConfigureInterface is unsupported off Linux. func ConfigureInterface(Config) error { return errUnsupported } + +// SetLinkUp is unsupported off Linux. +func SetLinkUp(string) error { return errUnsupported } + +// AddAddress is unsupported off Linux. +func AddAddress(string, netip.Prefix) error { return errUnsupported } diff --git a/internal/init/netlink/netlink_test.go b/internal/init/netlink/netlink_test.go index 3adc5eb..0551f42 100644 --- a/internal/init/netlink/netlink_test.go +++ b/internal/init/netlink/netlink_test.go @@ -275,6 +275,65 @@ func TestBringUpLoopback_Builds(t *testing.T) { } } +func TestSetLinkUp_SendsOneNewLink(t *testing.T) { + f := &fakeNetlink{indexByName: map[string]int{"eth0": 3}} + if err := setLinkUp("eth0", f.index, f.send); err != nil { + t.Fatalf("setLinkUp: %v", err) + } + if len(f.sent) != 1 || native.Uint16(f.sent[0][4:6]) != rtmNewLink { + t.Fatalf("sent %d requests, want one RTM_NEWLINK", len(f.sent)) + } + if native.Uint32(f.sent[0][nlmsghdrLen+4:nlmsghdrLen+8]) != 3 { + t.Error("ifi_index != 3") + } +} + +// A self-assigned link-local address is added beside whatever the interface +// has: no flush, no dump, no default route. +func TestAddAddress_LinkUpThenAddrOnly(t *testing.T) { + f := &fakeNetlink{indexByName: map[string]int{"eth0": 3}} + p := netip.MustParsePrefix("169.254.12.34/16") + if err := addAddress("eth0", p, f.index, f.send); err != nil { + t.Fatalf("addAddress: %v", err) + } + if len(f.sent) != 2 { + t.Fatalf("sent %d requests, want 2 (link up, addr)", len(f.sent)) + } + for i, want := range []uint16{rtmNewLink, rtmNewAddr} { + if got := native.Uint16(f.sent[i][4:6]); got != want { + t.Errorf("request[%d] type = %d, want %d", i, got, want) + } + } + body := f.sent[1][nlmsghdrLen:] + if body[1] != 16 { + t.Errorf("prefixlen = %d, want 16", body[1]) + } +} + +func TestAddAddress_Errors(t *testing.T) { + f := &fakeNetlink{indexByName: map[string]int{"eth0": 3}} + if err := addAddress("eth0", netip.MustParsePrefix("fe80::1/64"), f.index, f.send); err == nil { + t.Error("IPv6 prefix should error") + } + if err := addAddress("eth0", netip.Prefix{}, f.index, f.send); err == nil { + t.Error("zero prefix should error") + } + if len(f.sent) != 0 { + t.Errorf("invalid prefix still sent %d requests", len(f.sent)) + } + fi := &fakeNetlink{indexErr: errors.New("no such device")} + if err := addAddress("eth0", netip.MustParsePrefix("169.254.1.1/16"), fi.index, fi.send); err == nil { + t.Error("index error should propagate") + } + fs := &fakeNetlink{indexByName: map[string]int{"eth0": 3}, sendErr: errors.New("boom")} + if err := addAddress("eth0", netip.MustParsePrefix("169.254.1.1/16"), fs.index, fs.send); err == nil { + t.Error("send error should propagate") + } + if len(fs.sent) != 1 { + t.Errorf("after link-up failure, sent = %d, want 1 (aborts)", len(fs.sent)) + } +} + // buildFakeAddrMsg assembles a minimal RTM_NEWADDR netlink message for // ifIndex with the given IPv4 prefix. Used to seed parseAddrMessages tests // and the configure flush tests. From 6ad795f4a8ff35e58d576f113b5c556698378e0c Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:55:40 -0400 Subject: [PATCH 2/2] docs(management-trust): say how each laptop OS gets a link-local address Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- docs/management-trust.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/management-trust.md b/docs/management-trust.md index 6fb3dd7..6bd8d0e 100644 --- a/docs/management-trust.md +++ b/docs/management-trust.md @@ -99,8 +99,9 @@ example), the node assigns itself an IPv4 link-local address (RFC 3927) in that address instead. The address is derived from the interface's MAC, so the node comes back on the same one every boot. A routable address always takes priority: the link-local one is only assigned, and only shown, when the node -has no other IPv4 address. Give the laptop a self-assigned 169.254.x.x address -too (most operating systems do this on their own when DHCP gets no answer) and +has no other IPv4 address. Give the laptop a 169.254.x.x address too (macOS +and Windows assign one on their own when DHCP gets no answer; on Linux set the +connection's IPv4 method to link-local, or add one with `ip addr add`) and reach the node at the console's address: ```sh