mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
345 lines
10 KiB
Go
345 lines
10 KiB
Go
//go:build linux
|
|
|
|
package network
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/vishvananda/netlink"
|
|
"go4.org/netipx"
|
|
"golang.org/x/sys/unix"
|
|
"golang.zx2c4.com/wireguard/wgctrl"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
"log/slog"
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
"uncloud/internal/secret"
|
|
)
|
|
|
|
type WireGuardNetwork struct {
|
|
link netlink.Link
|
|
// peers is a map of peers indexed by their public key.
|
|
peers map[string]*peer
|
|
// mu synchronises concurrent network configuration changes.
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func NewWireGuardNetwork() (*WireGuardNetwork, error) {
|
|
link, err := createOrGetLink(WireGuardInterfaceName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create or get WireGuard link %q: %v", WireGuardInterfaceName, err)
|
|
}
|
|
return &WireGuardNetwork{
|
|
link: link,
|
|
peers: make(map[string]*peer),
|
|
}, nil
|
|
}
|
|
|
|
// createOrGetLink creates a new WireGuard link with the given name if it doesn't already exist, otherwise it returns the existing link.
|
|
func createOrGetLink(name string) (netlink.Link, error) {
|
|
link, err := netlink.LinkByName(name)
|
|
if err == nil {
|
|
slog.Info("Found existing WireGuard interface.", "name", name)
|
|
return link, nil
|
|
}
|
|
//goland:noinspection GoTypeAssertionOnErrors
|
|
if _, ok := err.(netlink.LinkNotFoundError); !ok {
|
|
return nil, fmt.Errorf("find WireGuard link %q: %v", name, err)
|
|
}
|
|
link = &netlink.GenericLink{
|
|
// TODO: figure out how to set the most appropriate MTU.
|
|
LinkAttrs: netlink.LinkAttrs{Name: name},
|
|
LinkType: "wireguard",
|
|
}
|
|
if err = netlink.LinkAdd(link); err != nil {
|
|
return nil, fmt.Errorf("create WireGuard link %q: %v", name, err)
|
|
}
|
|
slog.Info("Created WireGuard interface.", "name", name)
|
|
|
|
// Refetch the link to get the most up-to-date information.
|
|
link, err = netlink.LinkByName(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find created WireGuard link %q: %v", name, err)
|
|
}
|
|
return link, nil
|
|
}
|
|
|
|
// Configure applies the given configuration to the WireGuard network interface.
|
|
// It updates device and peers settings, subnet, and peer routes.
|
|
func (n *WireGuardNetwork) Configure(config Config) error {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
|
|
// Create or update peer structs based on the config.
|
|
newPeersSet := make(map[string]struct{}, len(config.Peers))
|
|
for _, pc := range config.Peers {
|
|
if p, ok := n.peers[pc.PublicKey.String()]; ok {
|
|
p.updateConfig(pc)
|
|
} else {
|
|
n.peers[pc.PublicKey.String()] = newPeer(pc)
|
|
}
|
|
newPeersSet[pc.PublicKey.String()] = struct{}{}
|
|
}
|
|
// Delete peers that are no longer in the config.
|
|
for k := range n.peers {
|
|
if _, ok := newPeersSet[k]; !ok {
|
|
delete(n.peers, k)
|
|
}
|
|
}
|
|
|
|
wg, err := wgctrl.New()
|
|
if err != nil {
|
|
return fmt.Errorf("create WireGuard client: %w", err)
|
|
}
|
|
defer wg.Close()
|
|
|
|
wgConfig, err := config.toDeviceConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Apply the new configuration to the WireGuard device.
|
|
if err = wg.ConfigureDevice(n.link.Attrs().Name, wgConfig); err != nil {
|
|
return fmt.Errorf("configure WireGuard device %q: %w", n.link.Attrs().Name, err)
|
|
}
|
|
slog.Info("Configured WireGuard interface.", "name", n.link.Attrs().Name)
|
|
|
|
if err = n.updatePeersFromWireGuard(); err != nil {
|
|
return err
|
|
}
|
|
slog.Debug("Updated peers status from WireGuard interface.", "name", n.link.Attrs().Name)
|
|
|
|
machinePrefix := netip.PrefixFrom(MachineIP(config.Subnet), config.Subnet.Bits())
|
|
managementPrefix, err := addrToSingleIPPrefix(config.ManagementIP)
|
|
if err != nil {
|
|
return fmt.Errorf("parse management IP: %w", err)
|
|
}
|
|
addrs := []netip.Prefix{managementPrefix, machinePrefix}
|
|
if err = n.updateAddresses(addrs); err != nil {
|
|
return err
|
|
}
|
|
slog.Info(
|
|
"Updated addresses of the WireGuard interface.",
|
|
"name", n.link.Attrs().Name, "addrs", addrs,
|
|
)
|
|
|
|
// Bring the WireGuard interface up if it's not already up.
|
|
if n.link.Attrs().Flags&unix.IFF_UP != unix.IFF_UP {
|
|
if err = netlink.LinkSetUp(n.link); err != nil {
|
|
return fmt.Errorf("set WireGuard link %q up: %w", n.link.Attrs().Name, err)
|
|
}
|
|
slog.Info("Brought WireGuard interface up.", "name", n.link.Attrs().Name)
|
|
}
|
|
if err = n.updatePeerRoutes(); err != nil {
|
|
return err
|
|
}
|
|
slog.Info(
|
|
"Updated routes to peers via the WireGuard interface.",
|
|
"name", n.link.Attrs().Name, "peers", len(n.peers),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// updatePeersFromWireGuard updates the peers status from the WireGuard device peers.
|
|
// mu lock must be held before calling this method.
|
|
func (n *WireGuardNetwork) updatePeersFromWireGuard() error {
|
|
wg, err := wgctrl.New()
|
|
if err != nil {
|
|
return fmt.Errorf("create WireGuard client: %w", err)
|
|
}
|
|
defer wg.Close()
|
|
|
|
dev, err := wg.Device(n.link.Attrs().Name)
|
|
if err != nil {
|
|
return fmt.Errorf("get WireGuard device %q: %w", n.link.Attrs().Name, err)
|
|
}
|
|
|
|
for _, wgPeer := range dev.Peers {
|
|
if p, ok := n.peers[secret.Secret(wgPeer.PublicKey[:]).String()]; ok {
|
|
p.updateFromWireGuard(wgPeer)
|
|
} else {
|
|
// Assume that WG peers are not updated out of band so they should always be in sync with the config.
|
|
slog.Warn("Found WireGuard peer that is not in the configuration.", "public_key", wgPeer.PublicKey)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updateAddresses assigns addresses to the WireGuard interface and removes old ones.
|
|
// It also removes any other addresses that have been added out of band.
|
|
func (n *WireGuardNetwork) updateAddresses(addrs []netip.Prefix) error {
|
|
for _, addr := range addrs {
|
|
ipNet := prefixToIPNet(addr)
|
|
if err := netlink.AddrAdd(n.link, &netlink.Addr{IPNet: &ipNet}); err != nil {
|
|
if !errors.Is(err, unix.EEXIST) {
|
|
return fmt.Errorf("add subnet address to WireGuard link %q: %w", n.link.Attrs().Name, err)
|
|
}
|
|
}
|
|
}
|
|
// Remove the old addresses or any other addresses that have been added out of band.
|
|
linkAddrs, err := netlink.AddrList(n.link, netlink.FAMILY_ALL)
|
|
if err != nil {
|
|
return fmt.Errorf("list addresses on WireGuard link %q: %w", n.link.Attrs().Name, err)
|
|
}
|
|
for _, linkAddr := range linkAddrs {
|
|
if slices.ContainsFunc(
|
|
addrs, func(a netip.Prefix) bool {
|
|
return linkAddr.IPNet.String() == a.String()
|
|
},
|
|
) {
|
|
continue
|
|
}
|
|
if err = netlink.AddrDel(n.link, &linkAddr); err != nil {
|
|
return fmt.Errorf("remove address %q from WireGuard link %q: %w", linkAddr.IPNet, n.link.Attrs().Name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updatePeerRoutes adds routes to the peers via the WireGuard interface and removes old routes to peers
|
|
// that are no longer in the configuration.
|
|
func (n *WireGuardNetwork) updatePeerRoutes() error {
|
|
// Build a set of compacted IP ranges for all peers.
|
|
var ipsetBuilder netipx.IPSetBuilder
|
|
for _, p := range n.peers {
|
|
prefixes, err := p.config.prefixes()
|
|
if err != nil {
|
|
return fmt.Errorf("get peer addresses: %w", err)
|
|
}
|
|
for _, pref := range prefixes {
|
|
ipsetBuilder.AddPrefix(pref)
|
|
}
|
|
}
|
|
ipset, err := ipsetBuilder.IPSet()
|
|
if err != nil {
|
|
return fmt.Errorf("build list of IP ranges for peers: %w", err)
|
|
}
|
|
|
|
// Add routes to the computed IP ranges via the WireGuard link.
|
|
for _, prefix := range ipset.Prefixes() {
|
|
dst := prefixToIPNet(prefix)
|
|
if err = netlink.RouteAdd(
|
|
&netlink.Route{
|
|
LinkIndex: n.link.Attrs().Index,
|
|
Scope: netlink.SCOPE_LINK,
|
|
Dst: &dst,
|
|
},
|
|
); err != nil && !errors.Is(err, unix.EEXIST) {
|
|
return fmt.Errorf("add route to WireGuard link %q: %w", n.link.Attrs().Name, err)
|
|
}
|
|
slog.Debug(
|
|
"Added route to peer(s) via WireGuard interface.",
|
|
"name", n.link.Attrs().Name, "dst", prefix,
|
|
)
|
|
}
|
|
|
|
// Remove old routes to IP ranges that are no longer in the configuration.
|
|
addedRoutes := ipset.Prefixes()
|
|
routes, err := netlink.RouteList(n.link, netlink.FAMILY_ALL)
|
|
if err != nil {
|
|
return fmt.Errorf("list routes on WireGuard link %q: %w", n.link.Attrs().Name, err)
|
|
}
|
|
for _, route := range routes {
|
|
routePrefix, pErr := ipNetToPrefix(*route.Dst)
|
|
if pErr != nil {
|
|
return fmt.Errorf("parse route destination: %w", pErr)
|
|
}
|
|
if slices.Contains(addedRoutes, routePrefix) {
|
|
continue
|
|
}
|
|
if err = netlink.RouteDel(&route); err != nil {
|
|
return fmt.Errorf("remove route %q from WireGuard link %q: %w", route.Dst, n.link.Attrs().Name, err)
|
|
}
|
|
slog.Debug(
|
|
"Removed route to peer(s) via WireGuard interface.",
|
|
"name", n.link.Attrs().Name, "dst", routePrefix,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *WireGuardNetwork) Run(ctx context.Context) error {
|
|
wg, err := wgctrl.New()
|
|
if err != nil {
|
|
return fmt.Errorf("create WireGuard client: %w", err)
|
|
}
|
|
defer wg.Close()
|
|
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
n.mu.Lock()
|
|
if err = n.changeWireGuardEndpoints(); err != nil {
|
|
slog.Error("Failed to update peer endpoints on WireGuard interface.",
|
|
"name", n.link.Attrs().Name, "err", err)
|
|
}
|
|
if err = n.updatePeersFromWireGuard(); err != nil {
|
|
slog.Error("Failed to update peers status from WireGuard interface.",
|
|
"name", n.link.Attrs().Name, "err", err)
|
|
}
|
|
n.mu.Unlock()
|
|
|
|
// TODO: notify the controller through a channel to preserve the change in the machine state.
|
|
case <-ctx.Done():
|
|
return nil
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// changeWireGuardEndpoints rotates the endpoints of the WireGuard peers that need to be changed.
|
|
func (n *WireGuardNetwork) changeWireGuardEndpoints() error {
|
|
var wgPeerConfigs []wgtypes.PeerConfig
|
|
for _, p := range n.peers {
|
|
newEndpoint, ok := p.shouldChangeEndpoint()
|
|
if !ok {
|
|
continue
|
|
}
|
|
newConfig := p.config
|
|
newConfig.Endpoint = &newEndpoint
|
|
p.updateConfig(newConfig)
|
|
|
|
publicKey, err := wgtypes.NewKey(p.config.PublicKey)
|
|
if err != nil {
|
|
return fmt.Errorf("parse peer public key: %w", err)
|
|
}
|
|
wgPeerConfigs = append(wgPeerConfigs, wgtypes.PeerConfig{
|
|
PublicKey: publicKey,
|
|
UpdateOnly: true,
|
|
Endpoint: &net.UDPAddr{
|
|
IP: p.config.Endpoint.Addr().AsSlice(),
|
|
Port: int(p.config.Endpoint.Port()),
|
|
},
|
|
})
|
|
}
|
|
if len(wgPeerConfigs) == 0 {
|
|
// No changes to the endpoints.
|
|
return nil
|
|
}
|
|
|
|
wg, err := wgctrl.New()
|
|
if err != nil {
|
|
return fmt.Errorf("create WireGuard client: %w", err)
|
|
}
|
|
defer wg.Close()
|
|
|
|
wgConfigPatch := wgtypes.Config{
|
|
ReplacePeers: false,
|
|
Peers: wgPeerConfigs,
|
|
}
|
|
// Apply the configuration patch to the WireGuard device.
|
|
if err = wg.ConfigureDevice(n.link.Attrs().Name, wgConfigPatch); err != nil {
|
|
return fmt.Errorf("configure WireGuard device %q with endpoint changes: %w", n.link.Attrs().Name, err)
|
|
}
|
|
for _, pc := range wgPeerConfigs {
|
|
slog.Info("Changed peer endpoint on WireGuard interface.",
|
|
"name", n.link.Attrs().Name, "public_key", pc.PublicKey.String(), "endpoint", pc.Endpoint)
|
|
}
|
|
return nil
|
|
}
|