diff --git a/kernel.go b/kernel.go index c3dbd32..09ce6f8 100644 --- a/kernel.go +++ b/kernel.go @@ -3,6 +3,8 @@ package main import ( "log/slog" "net/netip" + "os" + "strings" "sync" "time" ) @@ -38,6 +40,15 @@ type Kernel interface { Close() error } +// readSysctl returns the trimmed content of a /proc/sys file, or "". +func readSysctl(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + // Reconciler applies the config to the kernel whenever it is triggered and // remembers the outcome for the health report. type Reconciler struct { diff --git a/kernel_linux.go b/kernel_linux.go index 815328d..008c01c 100644 --- a/kernel_linux.go +++ b/kernel_linux.go @@ -3,13 +3,13 @@ package main import ( + "cmp" "errors" "fmt" "net" "net/netip" "os" "slices" - "strings" "github.com/vishvananda/netlink" "golang.zx2c4.com/wireguard/wgctrl" @@ -318,14 +318,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) { return false, "no address on " + uplink } -func readSysctl(path string) string { - b, err := os.ReadFile(path) - if err != nil { - return "" - } - return strings.TrimSpace(string(b)) -} - func (k *linuxKernel) Checks(c *Config) []Check { var out []Check link, err := netlink.LinkByName(c.Server.Interface) @@ -341,6 +333,19 @@ func (k *linuxKernel) Checks(c *Config) []Check { v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding") out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v}) } + // With IPv6 forwarding on, accept_ra 1 means router announcements are + // ignored: an IPv6 route learned from them expires (see sysctlConf). + if readSysctl("/proc/sys/net/ipv6/conf/all/forwarding") == "1" { + up := cmp.Or(k.Uplink(c, true), k.Uplink(c, false)) + if ra := readSysctl("/proc/sys/net/ipv6/conf/" + up + "/accept_ra"); up != "" && ra != "" { + ok := ra != "1" + detail := "net.ipv6.conf." + up + ".accept_ra=" + ra + if !ok { + detail += ": IPv6 from router announcements stops working; run " + appName + " update" + } + out = append(out, Check{"IPv6 router announcements", ok, detail}) + } + } ok, detail := firewallPresent() out = append(out, Check{"nftables rules", ok, detail}) up4 := k.Uplink(c, false) diff --git a/main_test.go b/main_test.go index 03afa84..6c2fe0a 100644 --- a/main_test.go +++ b/main_test.go @@ -12,6 +12,7 @@ import ( "net/netip" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -376,6 +377,38 @@ func TestUnitFile(t *testing.T) { } } +func TestSysctlConf(t *testing.T) { + dir := t.TempDir() + conf, sys := filepath.Join(dir, "conf"), filepath.Join(dir, "net") + for name, ra := range map[string]string{"eth0": "1", "wlan0": "2", "eth1": "0", "br0": "1", "veth1": "1", "lo": "1"} { + _ = os.MkdirAll(filepath.Join(conf, name), 0o755) + _ = os.WriteFile(filepath.Join(conf, name, "accept_ra"), []byte(ra+"\n"), 0o644) + } + for _, name := range []string{"eth0", "wlan0", "eth1"} { // network cards + _ = os.MkdirAll(filepath.Join(sys, name, "device"), 0o755) + } + _ = os.MkdirAll(filepath.Join(sys, "veth1"), 0o755) + // br0 carries the default route; the lo line is the kernel's unreachable route. + routes := filepath.Join(dir, "ipv6_route") + _ = os.WriteFile(routes, []byte( + "00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 br0\n"+ + "00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n"), 0o644) + + got := raInterfaces(conf, sys, routes) + if want := []string{"br0", "eth0", "wlan0"}; !slices.Equal(got, want) { + t.Fatalf("raInterfaces = %v, want %v", got, want) + } + c := sysctlConf(got) + for _, want := range []string{"net.ipv6.conf.all.forwarding=1\n", "net.ipv6.conf.default.accept_ra=2\n", "net.ipv6.conf.eth0.accept_ra=2\n", "net.ipv6.conf.br0.accept_ra=2\n"} { + if !strings.Contains(c, want) { + t.Errorf("sysctl conf lacks %q:\n%s", want, c) + } + } + if strings.Contains(c, "eth1") || strings.Contains(c, "veth1") { + t.Errorf("sysctl conf names eth1 (accept_ra 0) or veth1 (virtual):\n%s", c) + } +} + func TestWriteIfChanged(t *testing.T) { p := filepath.Join(t.TempDir(), "x.conf") if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil { diff --git a/setup.go b/setup.go index 1fe915b..f1867ae 100644 --- a/setup.go +++ b/setup.go @@ -12,6 +12,7 @@ import ( "os/user" "path/filepath" "runtime" + "slices" "strconv" "strings" "time" @@ -273,7 +274,51 @@ WantedBy=multi-user.target // rewrite the unit for every release. const unitVersion = "unit-1" -const sysctlConf = "net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\n" +// sysctlConf turns on forwarding. With IPv6 forwarding on, Linux ignores +// router announcements unless accept_ra is 2, and a server that gets its +// IPv6 route from them (SLAAC, e.g. a Raspberry Pi at home) loses IPv6 when +// the route expires. So every interface in ras keeps accepting them, as +// pivpn does for its uplink. +func sysctlConf(ras []string) string { + var b strings.Builder + b.WriteString("net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\nnet.ipv6.conf.default.accept_ra=2\n") + for _, name := range ras { + fmt.Fprintf(&b, "net.ipv6.conf.%s.accept_ra=2\n", name) + } + return b.String() +} + +// raInterfaces returns the network cards and the interface of the IPv6 +// default route, except those where router announcements are switched off +// (accept_ra 0). The directories are /proc/sys/net/ipv6/conf and +// /sys/class/net, routes is /proc/net/ipv6_route. +func raInterfaces(confDir, netDir, routes string) []string { + want := map[string]bool{} + if b, err := os.ReadFile(routes); err == nil { + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) == 10 && f[0] == strings.Repeat("0", 32) && f[1] == "00" && f[9] != "lo" { + want[f[9]] = true + } + } + } + entries, _ := os.ReadDir(netDir) + for _, e := range entries { + // Only real devices: bridges, veth and tunnels come and go. + if _, err := os.Stat(filepath.Join(netDir, e.Name(), "device")); err == nil { + want[e.Name()] = true + } + } + var out []string + for name := range want { + v := readSysctl(filepath.Join(confDir, name, "accept_ra")) + if v == "1" || v == "2" { + out = append(out, name) + } + } + slices.Sort(out) + return out +} // writeSystemFiles writes the unit, sysctl and module files. It reports // whether the unit changed (systemd must then reload). @@ -281,7 +326,8 @@ func writeSystemFiles() (unitChanged bool, err error) { if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil { return false, err } - sysChanged, err := writeIfChanged(sysctlPath, sysctlConf, 0o644) + ras := raInterfaces("/proc/sys/net/ipv6/conf", "/sys/class/net", "/proc/net/ipv6_route") + sysChanged, err := writeIfChanged(sysctlPath, sysctlConf(ras), 0o644) if err != nil { return false, err }