diff --git a/docs/management-trust.md b/docs/management-trust.md index 1bd957e..6bd8d0e 100644 --- a/docs/management-trust.md +++ b/docs/management-trust.md @@ -93,6 +93,25 @@ 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 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 +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 +139,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.