初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+45
View File
@@ -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
}
+85
View File
@@ -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")
}
}
+68
View File
@@ -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
}
+302
View File
@@ -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
}