Files
Daniel Redetzke a59a095691 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.
2026-10-05 00:10:13 +03:00

112 lines
2.7 KiB
Go

package main
import (
"log/slog"
"net/netip"
"os"
"strings"
"sync"
"time"
)
// PeerSample is one reading of a peer's kernel counters.
type PeerSample struct {
PublicKey string
RxBytes int64 // received by the server = uploaded by the peer
TxBytes int64 // sent by the server = downloaded by the peer
LastHandshake time.Time
Endpoint string
}
// Check is one line of the health report.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// Kernel applies the desired state to the system. The Linux implementation
// uses netlink, wgctrl and nftables; other platforms get a simulator so the
// web UI can be developed without a Linux box.
type Kernel interface {
Apply(c *Config) error
Sample(iface string) ([]PeerSample, error)
Checks(c *Config) []Check
Uplink(c *Config, v6 bool) string
// Ping sends one echo request to each address and returns the round-trip
// times of the replies that came within timeout.
Ping(dsts []netip.Addr, timeout time.Duration) (map[netip.Addr]time.Duration, error)
Down(c *Config) 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
// remembers the outcome for the health report.
type Reconciler struct {
kernel Kernel
store *Store
trigger chan struct{}
mu sync.Mutex
lastErr error
lastApply time.Time
}
func newReconciler(k Kernel, s *Store) *Reconciler {
return &Reconciler{kernel: k, store: s, trigger: make(chan struct{}, 1)}
}
// Kick schedules an apply; several kicks in a row collapse into one.
func (r *Reconciler) Kick() {
select {
case r.trigger <- struct{}{}:
default:
}
}
// ApplyNow applies synchronously and returns the result, so an API call can
// report kernel errors to the user.
func (r *Reconciler) ApplyNow() error {
err := r.kernel.Apply(r.store.Get())
r.mu.Lock()
r.lastErr, r.lastApply = err, time.Now()
r.mu.Unlock()
if err != nil {
slog.Error("apply failed", "err", err)
} else {
slog.Debug("config applied to kernel")
}
return err
}
// Run applies on every kick and re-applies every 5 minutes, which repairs
// drift such as a flushed nftables ruleset or a deleted interface.
func (r *Reconciler) Run(stop <-chan struct{}) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-stop:
return
case <-r.trigger:
case <-t.C:
}
_ = r.ApplyNow()
}
}
func (r *Reconciler) Status() (time.Time, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.lastApply, r.lastErr
}