初版功能完成
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// Package serverwg creates and configures the Linux kernel WireGuard hub.
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
const DefaultInterfaceName = "wg0"
|
||||
|
||||
// Config contains kernel interface and host-forwarding settings.
|
||||
type Config struct {
|
||||
InterfaceName string
|
||||
Address netip.Prefix
|
||||
ListenPort int
|
||||
PrivateKeyPath string
|
||||
EnableForwarding bool
|
||||
IPTablesPath string
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if c.InterfaceName == "" {
|
||||
return errors.New("WireGuard interface name must not be empty")
|
||||
}
|
||||
if !c.Address.Addr().Is4() || c.Address.Bits() > 30 {
|
||||
return errors.New("WireGuard address must be an IPv4 interface prefix")
|
||||
}
|
||||
if c.Address.Addr() == c.Address.Masked().Addr() {
|
||||
return errors.New("WireGuard address must be a usable host address, not its network address")
|
||||
}
|
||||
if c.ListenPort < 1 || c.ListenPort > 65535 {
|
||||
return fmt.Errorf("WireGuard listen port %d is outside 1..65535", c.ListenPort)
|
||||
}
|
||||
if c.PrivateKeyPath == "" {
|
||||
return errors.New("Server private key path must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Peer is one Node's cryptokey-routing entry.
|
||||
type Peer struct {
|
||||
PublicKey string
|
||||
Address netip.Addr
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
valid := Config{
|
||||
InterfaceName: "wg0", Address: netip.MustParsePrefix("10.88.0.1/16"),
|
||||
ListenPort: 51820, PrivateKeyPath: filepath.Join(t.TempDir(), "server.key"),
|
||||
}
|
||||
if err := valid.validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := valid
|
||||
invalid.Address = netip.MustParsePrefix("10.88.0.0/16")
|
||||
if err := invalid.validate(); err == nil {
|
||||
t.Fatal("network address accepted as Server address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreatePrivateKeyIsConcurrentAndAtomic(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "keys", "server.key")
|
||||
const workers = 8
|
||||
keys := make(chan string, workers)
|
||||
errorsSeen := make(chan error, workers)
|
||||
var group sync.WaitGroup
|
||||
for range workers {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
key, err := LoadOrCreatePrivateKey(path)
|
||||
if err != nil {
|
||||
errorsSeen <- err
|
||||
return
|
||||
}
|
||||
keys <- key.String()
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(keys)
|
||||
close(errorsSeen)
|
||||
for err := range errorsSeen {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := ""
|
||||
for key := range keys {
|
||||
if want == "" {
|
||||
want = key
|
||||
}
|
||||
if key != want {
|
||||
t.Fatalf("concurrent creators observed different keys %s and %s", want, key)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != want+"\n" {
|
||||
t.Fatalf("published key file = %q, %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreatePrivateKeyIsStable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "keys", "server.key")
|
||||
first, err := LoadOrCreatePrivateKey(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := LoadOrCreatePrivateKey(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("Server private key changed on reload")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != first.String()+"\n" {
|
||||
t.Fatal("private key file has unexpected contents")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// LoadOrCreatePrivateKey reads a Server key or atomically creates a 0600 file.
|
||||
func LoadOrCreatePrivateKey(path string) (wgtypes.Key, error) {
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
key, err := wgtypes.ParseKey(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("parse Server private key %q: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("restrict Server private key permissions: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return wgtypes.Key{}, fmt.Errorf("read Server private key %q: %w", path, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("create Server key directory: %w", err)
|
||||
}
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("generate Server WireGuard private key: %w", err)
|
||||
}
|
||||
directory := filepath.Dir(path)
|
||||
file, err := os.CreateTemp(directory, ".server-wg-*.tmp")
|
||||
if err != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("create temporary Server private key: %w", err)
|
||||
}
|
||||
temporaryPath := file.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := file.Chmod(0o600); err != nil {
|
||||
file.Close()
|
||||
return wgtypes.Key{}, fmt.Errorf("restrict temporary Server private key: %w", err)
|
||||
}
|
||||
writeErr := func() error {
|
||||
if _, err := file.WriteString(key.String() + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}()
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("write Server private key: %w", writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return wgtypes.Key{}, fmt.Errorf("close Server private key: %w", closeErr)
|
||||
}
|
||||
// A hard link publishes the fully flushed inode without overwriting a key
|
||||
// concurrently created by another Server process. Temporary and final files
|
||||
// are guaranteed to be on the same data-directory filesystem.
|
||||
if err := os.Link(temporaryPath, path); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return LoadOrCreatePrivateKey(path)
|
||||
}
|
||||
return wgtypes.Key{}, fmt.Errorf("publish Server private key %q: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//go:build linux
|
||||
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// Manager owns RemLink's wg0 and its dynamic Node peers.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
config Config
|
||||
client *wgctrl.Client
|
||||
key wgtypes.Key
|
||||
}
|
||||
|
||||
// New creates/reuses the kernel WireGuard interface and applies hub settings.
|
||||
func New(ctx context.Context, config Config) (*Manager, error) {
|
||||
if err := config.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := LoadOrCreatePrivateKey(config.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
link, err := ensureLink(config.InterfaceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := configureAddress(link, config.Address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open wgctrl: %w", err)
|
||||
}
|
||||
manager := &Manager{config: config, client: client, key: key}
|
||||
if err := client.ConfigureDevice(config.InterfaceName, wgtypes.Config{
|
||||
PrivateKey: &key, ListenPort: &config.ListenPort,
|
||||
}); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("configure kernel WireGuard interface %s: %w", config.InterfaceName, err)
|
||||
}
|
||||
if err := netlink.LinkSetUp(link); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("bring up %s: %w", config.InterfaceName, err)
|
||||
}
|
||||
if config.EnableForwarding {
|
||||
if err := enableIPv4Forwarding(); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureHubForwardRule(ctx, config); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// PublicKey is safe to return in Bootstrap responses.
|
||||
func (m *Manager) PublicKey() string { return m.key.PublicKey().String() }
|
||||
|
||||
// Close releases control sockets but deliberately leaves the kernel interface.
|
||||
func (m *Manager) Close() error { return m.client.Close() }
|
||||
|
||||
// EnsurePeer creates or replaces one Node's only AllowedIPs entry with /32.
|
||||
func (m *Manager) EnsurePeer(ctx context.Context, publicKey string, address netip.Addr) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
peer, err := peerConfig(Peer{PublicKey: publicKey, Address: address})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}}); err != nil {
|
||||
return fmt.Errorf("ensure WireGuard peer %s: %w", address, err)
|
||||
}
|
||||
// The kernel mutation has completed. A cancellation observed afterwards
|
||||
// must not be reported as failure, because callers may otherwise roll back
|
||||
// adjacent database state while leaving the peer applied.
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePeer revokes a Node's WireGuard public key.
|
||||
func (m *Manager) RemovePeer(ctx context.Context, publicKey string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := wgtypes.ParseKey(publicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard public key: %w", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{
|
||||
Peers: []wgtypes.PeerConfig{{PublicKey: key, Remove: true}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("remove WireGuard peer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastHandshake returns the kernel-observed handshake time for one peer.
|
||||
func (m *Manager) LastHandshake(ctx context.Context, publicKey string) (*time.Time, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := wgtypes.ParseKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse WireGuard public key: %w", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
device, err := m.client.Device(m.config.InterfaceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read WireGuard device %s: %w", m.config.InterfaceName, err)
|
||||
}
|
||||
for _, peer := range device.Peers {
|
||||
if peer.PublicKey == key {
|
||||
if peer.LastHandshakeTime.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
handshake := peer.LastHandshakeTime.UTC()
|
||||
return &handshake, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ReconcilePeers replaces kernel peers from the authoritative Node Registry.
|
||||
func (m *Manager) ReconcilePeers(ctx context.Context, peers []Peer) error {
|
||||
configs := make([]wgtypes.PeerConfig, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
config, err := peerConfig(peer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{
|
||||
ReplacePeers: true, Peers: configs,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("reconcile WireGuard peers: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconfigure atomically applies a new Server Overlay address/listen port and
|
||||
// complete peer set as part of Admin network migration.
|
||||
func (m *Manager) Reconfigure(ctx context.Context, address netip.Prefix, listenPort int, peers []Peer) error {
|
||||
next := m.config
|
||||
next.Address = address
|
||||
next.ListenPort = listenPort
|
||||
if err := next.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
configs := make([]wgtypes.PeerConfig, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
config, err := peerConfig(peer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
link, err := netlink.LinkByName(m.config.InterfaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up %s for reconfiguration: %w", m.config.InterfaceName, err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
oldAddress := m.config.Address
|
||||
if err := configureAddress(link, address); err != nil {
|
||||
_ = configureAddress(link, oldAddress)
|
||||
return err
|
||||
}
|
||||
if err := m.client.ConfigureDevice(m.config.InterfaceName, wgtypes.Config{
|
||||
ListenPort: &listenPort, ReplacePeers: true, Peers: configs,
|
||||
}); err != nil {
|
||||
_ = configureAddress(link, oldAddress)
|
||||
return fmt.Errorf("reconfigure kernel WireGuard: %w", err)
|
||||
}
|
||||
m.config = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func peerConfig(peer Peer) (wgtypes.PeerConfig, error) {
|
||||
key, err := wgtypes.ParseKey(peer.PublicKey)
|
||||
if err != nil {
|
||||
return wgtypes.PeerConfig{}, fmt.Errorf("parse WireGuard public key: %w", err)
|
||||
}
|
||||
if !peer.Address.Is4() {
|
||||
return wgtypes.PeerConfig{}, errors.New("WireGuard peer address must be IPv4")
|
||||
}
|
||||
bits := peer.Address.As4()
|
||||
allowedIP := net.IPNet{IP: net.IPv4(bits[0], bits[1], bits[2], bits[3]), Mask: net.CIDRMask(32, 32)}
|
||||
return wgtypes.PeerConfig{
|
||||
PublicKey: key, ReplaceAllowedIPs: true, AllowedIPs: []net.IPNet{allowedIP},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureLink(name string) (netlink.Link, error) {
|
||||
link, err := netlink.LinkByName(name)
|
||||
if err == nil {
|
||||
if link.Type() != "wireguard" {
|
||||
return nil, fmt.Errorf("interface %s exists with type %s, want wireguard", name, link.Type())
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
if _, notFound := err.(netlink.LinkNotFoundError); !notFound {
|
||||
return nil, fmt.Errorf("look up interface %s: %w", name, err)
|
||||
}
|
||||
link = &netlink.GenericLink{LinkAttrs: netlink.LinkAttrs{Name: name}, LinkType: "wireguard"}
|
||||
if err := netlink.LinkAdd(link); err != nil {
|
||||
return nil, fmt.Errorf("create kernel WireGuard interface %s: %w", name, err)
|
||||
}
|
||||
return netlink.LinkByName(name)
|
||||
}
|
||||
|
||||
func configureAddress(link netlink.Link, desired netip.Prefix) error {
|
||||
addresses, err := netlink.AddrList(link, netlink.FAMILY_V4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list %s addresses: %w", link.Attrs().Name, err)
|
||||
}
|
||||
desiredText := desired.String()
|
||||
for index := range addresses {
|
||||
if addresses[index].IPNet.String() == desiredText {
|
||||
continue
|
||||
}
|
||||
if err := netlink.AddrDel(link, &addresses[index]); err != nil {
|
||||
return fmt.Errorf("remove stale %s address %s: %w", link.Attrs().Name, addresses[index].IPNet, err)
|
||||
}
|
||||
}
|
||||
address, err := netlink.ParseAddr(desiredText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert WireGuard address: %w", err)
|
||||
}
|
||||
if err := netlink.AddrReplace(link, address); err != nil {
|
||||
return fmt.Errorf("configure %s address %s: %w", link.Attrs().Name, desired, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enableIPv4Forwarding() error {
|
||||
const path = "/proc/sys/net/ipv4/ip_forward"
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read IPv4 forwarding state: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(value)) == "1" {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("1\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("enable IPv4 forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureHubForwardRule(ctx context.Context, config Config) error {
|
||||
path := config.IPTablesPath
|
||||
if path == "" {
|
||||
var err error
|
||||
path, err = exec.LookPath("iptables")
|
||||
if err != nil {
|
||||
return errors.New("iptables is required to allow wg0-to-wg0 forwarding")
|
||||
}
|
||||
}
|
||||
arguments := []string{"FORWARD", "-i", config.InterfaceName, "-o", config.InterfaceName, "-j", "ACCEPT"}
|
||||
check := exec.CommandContext(ctx, path, append([]string{"-C"}, arguments...)...)
|
||||
if err := check.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
insert := exec.CommandContext(ctx, path, append([]string{"-I"}, arguments...)...)
|
||||
output, err := insert.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("allow %s-to-%s forwarding: %w: %s", config.InterfaceName, config.InterfaceName, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build !linux
|
||||
|
||||
package serverwg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrLinuxRequired = errors.New("kernel WireGuard Server requires Linux")
|
||||
|
||||
// Manager is unavailable on non-Linux build targets.
|
||||
type Manager struct{}
|
||||
|
||||
func New(context.Context, Config) (*Manager, error) { return nil, ErrLinuxRequired }
|
||||
func (*Manager) PublicKey() string { return "" }
|
||||
func (*Manager) Close() error { return nil }
|
||||
func (*Manager) EnsurePeer(context.Context, string, netip.Addr) error {
|
||||
return ErrLinuxRequired
|
||||
}
|
||||
func (*Manager) RemovePeer(context.Context, string) error { return ErrLinuxRequired }
|
||||
func (*Manager) ReconcilePeers(context.Context, []Peer) error {
|
||||
return ErrLinuxRequired
|
||||
}
|
||||
func (*Manager) Reconfigure(context.Context, netip.Prefix, int, []Peer) error {
|
||||
return ErrLinuxRequired
|
||||
}
|
||||
func (*Manager) LastHandshake(context.Context, string) (*time.Time, error) {
|
||||
return nil, ErrLinuxRequired
|
||||
}
|
||||
Reference in New Issue
Block a user