Keep IPv6 router announcements working with forwarding on

With net.ipv6.conf.all.forwarding=1, Linux ignores router announcements
unless accept_ra is 2, so a server that gets its IPv6 route by SLAAC
(e.g. a Raspberry Pi at home) lost IPv6 once the route expired.

The sysctl file now also sets accept_ra=2 for the default and for every
network card and the IPv6 default-route interface, except where
accept_ra is 0. "update" rewrites the file, which fixes existing
installs. A new health check warns while the uplink still has
accept_ra=1.
This commit is contained in:
Daniel Redetzke
2026-10-05 00:10:13 +03:00
parent d32e851b74
commit a59a095691
4 changed files with 106 additions and 11 deletions
+11
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"log/slog" "log/slog"
"net/netip" "net/netip"
"os"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -38,6 +40,15 @@ type Kernel interface {
Close() error 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 // Reconciler applies the config to the kernel whenever it is triggered and
// remembers the outcome for the health report. // remembers the outcome for the health report.
type Reconciler struct { type Reconciler struct {
+14 -9
View File
@@ -3,13 +3,13 @@
package main package main
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
"os" "os"
"slices" "slices"
"strings"
"github.com/vishvananda/netlink" "github.com/vishvananda/netlink"
"golang.zx2c4.com/wireguard/wgctrl" "golang.zx2c4.com/wireguard/wgctrl"
@@ -318,14 +318,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) {
return false, "no address on " + uplink 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 { func (k *linuxKernel) Checks(c *Config) []Check {
var out []Check var out []Check
link, err := netlink.LinkByName(c.Server.Interface) 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") v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v}) 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() ok, detail := firewallPresent()
out = append(out, Check{"nftables rules", ok, detail}) out = append(out, Check{"nftables rules", ok, detail})
up4 := k.Uplink(c, false) up4 := k.Uplink(c, false)
+33
View File
@@ -12,6 +12,7 @@ import (
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestWriteIfChanged(t *testing.T) {
p := filepath.Join(t.TempDir(), "x.conf") p := filepath.Join(t.TempDir(), "x.conf")
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil { if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
+48 -2
View File
@@ -12,6 +12,7 @@ import (
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -273,7 +274,51 @@ WantedBy=multi-user.target
// rewrite the unit for every release. // rewrite the unit for every release.
const unitVersion = "unit-1" 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 // writeSystemFiles writes the unit, sysctl and module files. It reports
// whether the unit changed (systemd must then reload). // 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 { if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
return false, err 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 { if err != nil {
return false, err return false, err
} }