301 lines
9.5 KiB
Go
301 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
|
|
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
|
|
|
|
"github.com/vishvananda/netlink"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
)
|
|
|
|
// wireguard is the part of wgctrl.Client the syncer uses, so tests can replace it.
|
|
type wireguard interface {
|
|
Device(name string) (*wgtypes.Device, error)
|
|
ConfigureDevice(name string, cfg wgtypes.Config) error
|
|
}
|
|
|
|
// syncer makes the AllowedIPs of the peers follow the routes of the interface.
|
|
//
|
|
// Routes only tell when to look. Every reconcile lists the routes and the
|
|
// peers anew, so lost or reordered route updates cannot leave a stale state.
|
|
// It is not safe for concurrent use, the run loop is the only caller.
|
|
type syncer struct {
|
|
iface string
|
|
filter FilterOptions
|
|
protected reconcile.Set
|
|
wg wireguard
|
|
|
|
// subscribe is replaced in tests
|
|
subscribe func() (<-chan netlink.RouteUpdate, chan struct{}, error)
|
|
|
|
// ifindex of the interface at the last reconcile, 0 if it did not exist
|
|
ifindex int
|
|
|
|
// known is the last index the interface had. Another one means it was recreated.
|
|
known int
|
|
|
|
// managed are the prefixes added or adopted because of a route. Others are
|
|
// never removed. It is kept in memory only.
|
|
managed reconcile.Set
|
|
|
|
// started is set once a reconcile of the interface got as far as planning, also
|
|
// if writing failed. Until then the prefixes present already are taken over if a route backs them.
|
|
started bool
|
|
|
|
// warned are the warnings logged for the current state, so they are not repeated
|
|
warned map[string]struct{}
|
|
}
|
|
|
|
func newSyncer(iface string, filter FilterOptions, protected reconcile.Set, client wireguard) *syncer {
|
|
return &syncer{
|
|
iface: iface,
|
|
filter: filter,
|
|
protected: protected,
|
|
wg: client,
|
|
subscribe: subscribeRoutes,
|
|
managed: make(reconcile.Set),
|
|
}
|
|
}
|
|
|
|
func (s *syncer) reconcile() error {
|
|
// The interface can be recreated, so it is resolved again every time
|
|
link, err := netlink.LinkByName(s.iface)
|
|
if err != nil {
|
|
s.ifindex = 0
|
|
return fmt.Errorf("couldn't get interface '%s': %w", s.iface, err)
|
|
}
|
|
s.observe(link.Attrs().Index)
|
|
|
|
filter, mask := s.filter.routeFilter(s.ifindex)
|
|
routeList, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, mask)
|
|
if err != nil {
|
|
return fmt.Errorf("couldn't get route-entries: %w", err)
|
|
}
|
|
|
|
device, err := s.wg.Device(s.iface)
|
|
if err != nil {
|
|
return fmt.Errorf("couldn't get wg-interface '%s': %w", s.iface, err)
|
|
}
|
|
|
|
routes, skipped := toRoutes(routeList)
|
|
plan := reconcile.Compute(reconcile.Input{
|
|
Routes: routes,
|
|
Peers: toPeers(device),
|
|
Managed: s.managed,
|
|
Protected: s.protected,
|
|
Startup: !s.started,
|
|
})
|
|
return s.commit(plan, skippedWarnings(skipped))
|
|
}
|
|
|
|
// commit applies the plan and takes over the resulting state. The notes are
|
|
// warnings about what was left out before the plan.
|
|
func (s *syncer) commit(plan reconcile.Plan, notes []string) error {
|
|
// From here on prefixes that are present are not taken over anymore, also when
|
|
// applying fails: the managed set below covers what the plan took over.
|
|
s.started = true
|
|
|
|
result, err := s.apply(plan)
|
|
s.warn(slices.Concat(notes, warnings(plan), result.warnings))
|
|
if err != nil {
|
|
// It is unknown how far the plan got, so keep what may belong to us
|
|
maps.Copy(s.managed, plan.Managed)
|
|
maps.Copy(s.managed, result.kept)
|
|
return err
|
|
}
|
|
|
|
s.managed = plan.Managed
|
|
maps.Copy(s.managed, result.kept)
|
|
return nil
|
|
}
|
|
|
|
// observe records the index of the interface. Another index than before means the
|
|
// interface was recreated, so what was learned about the old one is dropped. The
|
|
// prefixes of the new one are not ours, except the ones a route backs, like at the start.
|
|
func (s *syncer) observe(index int) {
|
|
s.ifindex = index
|
|
if s.known != 0 && s.known != index {
|
|
logger.Info.Printf("Interface '%s' was recreated, starting over", s.iface)
|
|
s.managed = make(reconcile.Set)
|
|
s.started = false
|
|
s.warned = nil
|
|
}
|
|
s.known = index
|
|
}
|
|
|
|
// outcome of applying a plan, besides the error
|
|
type outcome struct {
|
|
// warnings about changes that were not made
|
|
warnings []string
|
|
|
|
// kept are the prefixes that were to be removed and are still there. They stay managed.
|
|
kept reconcile.Set
|
|
}
|
|
|
|
// apply writes the changes of the plan to the wg-interface.
|
|
func (s *syncer) apply(plan reconcile.Plan) (outcome, error) {
|
|
// Adding a prefix to a peer takes it from the one having it. Only the new
|
|
// prefixes are sent, so nothing else on the peer is touched.
|
|
var add wgtypes.Config
|
|
for _, change := range plan.Changes {
|
|
if len(change.Add) == 0 {
|
|
continue
|
|
}
|
|
add.Peers = append(add.Peers, wgtypes.PeerConfig{
|
|
PublicKey: change.Peer,
|
|
UpdateOnly: true, // never recreate a peer removed in the meantime
|
|
AllowedIPs: toIPNets(change.Add),
|
|
})
|
|
}
|
|
if len(add.Peers) > 0 {
|
|
if err := s.wg.ConfigureDevice(s.iface, add); err != nil {
|
|
return outcome{}, fmt.Errorf("error adding to wg-device '%s': %w", s.iface, err)
|
|
}
|
|
for _, change := range plan.Changes {
|
|
for _, prefix := range change.Add {
|
|
logger.Info.Printf("[+] %s to peer %s", prefix, change.Peer)
|
|
}
|
|
}
|
|
}
|
|
|
|
return s.remove(plan.Changes)
|
|
}
|
|
|
|
// remove deletes prefixes from their peers. WireGuard can only replace the
|
|
// whole list, so it is read right before to keep the window for changes made
|
|
// by others small.
|
|
//
|
|
// The kernel drops the list first and then inserts the new entries one by one,
|
|
// until one is rejected. A list that cannot be written back completely would
|
|
// lose the entries from the rejected one on, so such a peer is left alone.
|
|
// Every peer is written on its own, so a failing one does not stop the others.
|
|
func (s *syncer) remove(changes []reconcile.Change) (outcome, error) {
|
|
result := outcome{kept: make(reconcile.Set)}
|
|
if !slices.ContainsFunc(changes, func(c reconcile.Change) bool { return len(c.Remove) > 0 }) {
|
|
return result, nil
|
|
}
|
|
|
|
device, err := s.wg.Device(s.iface)
|
|
if err != nil {
|
|
return result, fmt.Errorf("couldn't get wg-interface '%s': %w", s.iface, err)
|
|
}
|
|
current := make(map[wgtypes.Key][]net.IPNet)
|
|
for _, peer := range device.Peers {
|
|
current[peer.PublicKey] = peer.AllowedIPs
|
|
}
|
|
|
|
var errs []error
|
|
for _, change := range changes {
|
|
allowed := current[change.Peer]
|
|
left := withoutPrefixes(allowed, change.Remove)
|
|
if len(left) == len(allowed) { // gone already, or the peer is
|
|
continue
|
|
}
|
|
|
|
if i := slices.IndexFunc(left, func(n net.IPNet) bool { return !writable(n) }); i >= 0 {
|
|
result.warnings = append(result.warnings, fmt.Sprintf(
|
|
"Peer %s has the AllowedIP %s, which cannot be written back to Wireguard, so %v stay on the peer",
|
|
change.Peer, prefixString(left[i]), change.Remove))
|
|
for _, prefix := range change.Remove {
|
|
result.kept[prefix] = struct{}{}
|
|
}
|
|
continue
|
|
}
|
|
|
|
err := s.wg.ConfigureDevice(s.iface, wgtypes.Config{Peers: []wgtypes.PeerConfig{{
|
|
PublicKey: change.Peer,
|
|
UpdateOnly: true,
|
|
ReplaceAllowedIPs: true,
|
|
AllowedIPs: left,
|
|
}}})
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("error removing from peer %s of wg-device '%s': %w", change.Peer, s.iface, err))
|
|
continue
|
|
}
|
|
for _, prefix := range change.Remove {
|
|
logger.Info.Printf("[-] %s from peer %s", prefix, change.Peer)
|
|
}
|
|
}
|
|
return result, errors.Join(errs...)
|
|
}
|
|
|
|
// linkChanged tells if the interface is another one than at the last reconcile.
|
|
func (s *syncer) linkChanged() bool {
|
|
link, err := netlink.LinkByName(s.iface)
|
|
if err != nil {
|
|
return s.ifindex != 0
|
|
}
|
|
return link.Attrs().Index != s.ifindex
|
|
}
|
|
|
|
// watches tells if a route update may change what the last reconcile did.
|
|
func (s *syncer) watches(route netlink.Route) bool {
|
|
if s.ifindex != 0 && route.LinkIndex == s.ifindex {
|
|
return true
|
|
}
|
|
|
|
// A route of another interface can replace one of ours without a delete
|
|
dst, ok := routeDst(route)
|
|
_, managed := s.managed[dst]
|
|
return ok && managed
|
|
}
|
|
|
|
// warnings describes what a plan leaves out, in a stable order.
|
|
func warnings(plan reconcile.Plan) []string {
|
|
counts := make(map[netip.Addr]int)
|
|
for _, route := range plan.Unresolved {
|
|
counts[route.Gw]++
|
|
}
|
|
|
|
var messages []string
|
|
for gw, count := range counts {
|
|
messages = append(messages, fmt.Sprintf("No peer found containing gw-IP '%s', ignoring %d route(s)", gw, count))
|
|
}
|
|
for _, c := range plan.Conflicts {
|
|
messages = append(messages, fmt.Sprintf(
|
|
"Route to %s points to peer %s, but the prefix is on peer %s and not managed by this program, leaving it",
|
|
c.Dst, c.Wanted, c.Present))
|
|
}
|
|
slices.Sort(messages)
|
|
return messages
|
|
}
|
|
|
|
// skippedWarnings describes the routes that were left out because their destination
|
|
// cannot be written to WireGuard, once per destination.
|
|
func skippedWarnings(skipped []netip.Prefix) []string {
|
|
messages := make([]string, 0, len(skipped))
|
|
for _, prefix := range skipped {
|
|
messages = append(messages, fmt.Sprintf("Ignoring route to %s, WireGuard cannot take an IPv4-mapped IPv6 prefix as AllowedIP", prefix))
|
|
}
|
|
slices.Sort(messages)
|
|
return slices.Compact(messages)
|
|
}
|
|
|
|
// warn logs the messages that were not part of the last call. A condition that
|
|
// lasts is logged when it appears, not at every reconcile.
|
|
func (s *syncer) warn(messages []string) {
|
|
for _, message := range s.fresh(messages) {
|
|
logger.Warn.Print(message)
|
|
}
|
|
}
|
|
|
|
// fresh returns the messages that were not part of the last call and remembers the current ones.
|
|
func (s *syncer) fresh(messages []string) []string {
|
|
var fresh []string
|
|
current := make(map[string]struct{}, len(messages))
|
|
for _, message := range messages {
|
|
current[message] = struct{}{}
|
|
if _, seen := s.warned[message]; !seen {
|
|
fresh = append(fresh, message)
|
|
}
|
|
}
|
|
s.warned = current
|
|
return fresh
|
|
}
|