Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes.
288 lines
8.2 KiB
Go
288 lines
8.2 KiB
Go
//go:build linux
|
|
|
|
package wg
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/vishvananda/netlink"
|
|
"golang.zx2c4.com/wireguard/wgctrl"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
)
|
|
|
|
// linuxBackend drives a real WireGuard interface. In kernel mode the link is a
|
|
// native `wireguard` netlink link and every packet is handled by the module;
|
|
// in userspace mode a wireguard-go process owns a TUN device with the same
|
|
// name and WGX talks to it over its UAPI socket. Both are configured through
|
|
// wgctrl, which picks the transport on its own.
|
|
type linuxBackend struct {
|
|
name string
|
|
userspace bool
|
|
client *wgctrl.Client
|
|
proc *exec.Cmd
|
|
log *slog.Logger
|
|
}
|
|
|
|
// KernelAvailable reports whether the running kernel can create a WireGuard
|
|
// link. It tries to add and immediately delete a probe interface rather than
|
|
// trusting /sys/module, because a module that is loadable but not yet loaded
|
|
// is only discovered by asking for it.
|
|
func KernelAvailable() bool {
|
|
const probe = "wgxprobe0"
|
|
link := &netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: probe}}
|
|
if err := netlink.LinkAdd(link); err != nil {
|
|
return false
|
|
}
|
|
_ = netlink.LinkDel(link)
|
|
return true
|
|
}
|
|
|
|
// NewKernel returns a backend that uses the kernel module.
|
|
func NewKernel(name string, log *slog.Logger) (Backend, error) {
|
|
return newLinux(name, false, log)
|
|
}
|
|
|
|
// NewUserspace returns a backend that runs wireguard-go for the data plane.
|
|
func NewUserspace(name string, log *slog.Logger) (Backend, error) {
|
|
if _, err := exec.LookPath("wireguard-go"); err != nil {
|
|
return nil, errors.New("wireguard-go is not installed and the kernel has no WireGuard support")
|
|
}
|
|
return newLinux(name, true, log)
|
|
}
|
|
|
|
func newLinux(name string, userspace bool, log *slog.Logger) (Backend, error) {
|
|
c, err := wgctrl.New()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open wgctrl: %w", err)
|
|
}
|
|
return &linuxBackend{name: name, userspace: userspace, client: c, log: log}, nil
|
|
}
|
|
|
|
func (b *linuxBackend) Kind() string {
|
|
if b.userspace {
|
|
return "userspace"
|
|
}
|
|
return "kernel"
|
|
}
|
|
|
|
func (b *linuxBackend) Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error {
|
|
// A previous run that died without Down leaves the link behind. Start
|
|
// clean rather than inheriting peers and addresses nobody remembers.
|
|
if err := b.deleteLink(); err != nil {
|
|
return err
|
|
}
|
|
if b.userspace {
|
|
if err := b.startUserspace(ctx); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: b.name}}); err != nil {
|
|
return fmt.Errorf("create %s: %w (is the container running with NET_ADMIN?)", b.name, err)
|
|
}
|
|
}
|
|
link, err := b.link()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
priv := wgtypes.Key(cfg.PrivateKey)
|
|
port := cfg.ListenPort
|
|
wcfg := wgtypes.Config{PrivateKey: &priv, ListenPort: &port, ReplacePeers: true}
|
|
if cfg.FirewallMark != 0 {
|
|
fw := cfg.FirewallMark
|
|
wcfg.FirewallMark = &fw
|
|
}
|
|
if err := b.client.ConfigureDevice(b.name, wcfg); err != nil {
|
|
return fmt.Errorf("configure %s: %w", b.name, err)
|
|
}
|
|
for _, p := range addrs {
|
|
// Not prefixToIPNet: that masks the host bits, and an interface
|
|
// address must keep them (10.8.0.1/24, not 10.8.0.0/24).
|
|
ipn := addrToIPNet(p)
|
|
if err := netlink.AddrAdd(link, &netlink.Addr{IPNet: &ipn}); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return fmt.Errorf("add address %s: %w", p, err)
|
|
}
|
|
}
|
|
if mtu > 0 {
|
|
if err := netlink.LinkSetMTU(link, mtu); err != nil {
|
|
return fmt.Errorf("set mtu %d: %w", mtu, err)
|
|
}
|
|
}
|
|
if err := netlink.LinkSetUp(link); err != nil {
|
|
return fmt.Errorf("bring up %s: %w", b.name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *linuxBackend) startUserspace(ctx context.Context) error {
|
|
_ = os.MkdirAll("/var/run/wireguard", 0o700)
|
|
cmd := exec.Command("wireguard-go", "-f", b.name)
|
|
cmd.Env = append(os.Environ(), "WG_PROCESS_FOREGROUND=1", "LOG_LEVEL=error")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("start wireguard-go: %w", err)
|
|
}
|
|
b.proc = cmd
|
|
sock := filepath.Join("/var/run/wireguard", b.name+".sock")
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(sock); err == nil {
|
|
if _, err := netlink.LinkByName(b.name); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
return errors.New("wireguard-go did not create its UAPI socket in time")
|
|
}
|
|
|
|
func (b *linuxBackend) Down(ctx context.Context) error {
|
|
var errs []error
|
|
if err := b.deleteLink(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
if b.proc != nil && b.proc.Process != nil {
|
|
_ = b.proc.Process.Kill()
|
|
_ = b.proc.Wait()
|
|
b.proc = nil
|
|
}
|
|
if err := b.client.Close(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (b *linuxBackend) deleteLink() error {
|
|
link, err := netlink.LinkByName(b.name)
|
|
if err != nil {
|
|
var nf netlink.LinkNotFoundError
|
|
if errors.As(err, &nf) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("look up %s: %w", b.name, err)
|
|
}
|
|
if err := netlink.LinkDel(link); err != nil {
|
|
return fmt.Errorf("delete stale %s: %w", b.name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *linuxBackend) link() (netlink.Link, error) {
|
|
link, err := netlink.LinkByName(b.name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("look up %s: %w", b.name, err)
|
|
}
|
|
return link, nil
|
|
}
|
|
|
|
func (b *linuxBackend) Device(ctx context.Context) (*DeviceState, error) {
|
|
d, err := b.client.Device(b.name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", b.name, err)
|
|
}
|
|
st := &DeviceState{Name: d.Name, PublicKey: Key(d.PublicKey), ListenPort: d.ListenPort}
|
|
st.Peers = make([]PeerState, 0, len(d.Peers))
|
|
for _, p := range d.Peers {
|
|
ps := PeerState{
|
|
PublicKey: Key(p.PublicKey),
|
|
Endpoint: p.Endpoint,
|
|
LastHandshake: p.LastHandshakeTime,
|
|
ReceiveBytes: p.ReceiveBytes,
|
|
TransmitBytes: p.TransmitBytes,
|
|
PersistentKeepalive: p.PersistentKeepaliveInterval,
|
|
}
|
|
for _, a := range p.AllowedIPs {
|
|
if pfx, ok := ipNetToPrefix(a); ok {
|
|
ps.AllowedIPs = append(ps.AllowedIPs, pfx)
|
|
}
|
|
}
|
|
st.Peers = append(st.Peers, ps)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
func (b *linuxBackend) SetPeer(ctx context.Context, p PeerConfig) error {
|
|
return b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{toPeerConfig(p)}})
|
|
}
|
|
|
|
func (b *linuxBackend) RemovePeer(ctx context.Context, pub Key) error {
|
|
err := b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{{PublicKey: wgtypes.Key(pub), Remove: true}}})
|
|
if err != nil && strings.Contains(err.Error(), "no such") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (b *linuxBackend) ReplacePeers(ctx context.Context, peers []PeerConfig) error {
|
|
cfg := wgtypes.Config{ReplacePeers: true}
|
|
for _, p := range peers {
|
|
cfg.Peers = append(cfg.Peers, toPeerConfig(p))
|
|
}
|
|
return b.client.ConfigureDevice(b.name, cfg)
|
|
}
|
|
|
|
func (b *linuxBackend) SetMTU(ctx context.Context, mtu int) error {
|
|
link, err := b.link()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return netlink.LinkSetMTU(link, mtu)
|
|
}
|
|
|
|
func toPeerConfig(p PeerConfig) wgtypes.PeerConfig {
|
|
pc := wgtypes.PeerConfig{PublicKey: wgtypes.Key(p.PublicKey), ReplaceAllowedIPs: true}
|
|
if p.PresharedKey != nil {
|
|
psk := wgtypes.Key(*p.PresharedKey)
|
|
pc.PresharedKey = &psk
|
|
}
|
|
if p.PersistentKeepalive > 0 {
|
|
ka := p.PersistentKeepalive
|
|
pc.PersistentKeepaliveInterval = &ka
|
|
}
|
|
for _, a := range p.AllowedIPs {
|
|
pc.AllowedIPs = append(pc.AllowedIPs, prefixToIPNet(a))
|
|
}
|
|
return pc
|
|
}
|
|
|
|
// prefixToIPNet converts a route prefix; host bits are cleared.
|
|
func prefixToIPNet(p netip.Prefix) net.IPNet {
|
|
return addrToIPNet(p.Masked())
|
|
}
|
|
|
|
// addrToIPNet converts an interface address with its prefix length, keeping
|
|
// the host bits.
|
|
func addrToIPNet(p netip.Prefix) net.IPNet {
|
|
ip := p.Addr()
|
|
if ip.Is4() {
|
|
a := ip.As4()
|
|
return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 32)}
|
|
}
|
|
a := ip.As16()
|
|
return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 128)}
|
|
}
|
|
|
|
func ipNetToPrefix(n net.IPNet) (netip.Prefix, bool) {
|
|
addr, ok := netip.AddrFromSlice(n.IP)
|
|
if !ok {
|
|
return netip.Prefix{}, false
|
|
}
|
|
addr = addr.Unmap()
|
|
ones, _ := n.Mask.Size()
|
|
return netip.PrefixFrom(addr, ones), true
|
|
}
|