feat(client): add authorized WireGuard device enrollment and desktop connection
This commit is contained in:
1 parent
d2f1f5040e
commit
6dd3c7dd45
25 files changed
+3274
-131
No files matched your search
@@ -0,0 +1,280 @@
|
||||
// device.go 面向终端用户的客户端设备 Peer 管理。
|
||||
//
|
||||
// 与管理端的 AddPeer/RemovePeer 不同,这里登记的 Peer 全部由客户端
|
||||
// 自行生成并保管私钥(外部 Peer),服务端只保存公钥;隧道运行中通过
|
||||
// wg set 增量下发/移除,不重启接口、不中断其他 Peer 的现有连接。
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// IsWireGuardKey 校验 base64 编码的 32 字节 WireGuard 密钥格式。
|
||||
func IsWireGuardKey(key string) bool { return isWireGuardKey(key) }
|
||||
|
||||
// InterfaceName 返回隧道对应的内核接口名(与 Create/Start 使用的名字一致)。
|
||||
func InterfaceName(t *model.Tunnel) string { return interfaceName(t) }
|
||||
|
||||
// RegisterDevicePeer 把客户端设备公钥登记为外部 Peer 并立即生效。
|
||||
// 返回为该 Peer 生成的 PresharedKey(需随连接参数下发给设备)。
|
||||
func (s *Service) RegisterDevicePeer(t *model.Tunnel, id, name, publicKey, clientAddress string) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers = append(peers, peer{
|
||||
ID: id, Name: name, PublicKey: publicKey,
|
||||
ClientAddress: clientAddress, Enabled: true, Managed: false,
|
||||
})
|
||||
if err := validatePeerInput(peers); err != nil {
|
||||
return "", err
|
||||
}
|
||||
peerSecrets, err := s.persistPeers(t, cfg, secrets, peers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
preshared := peerSecrets[id].PresharedKey
|
||||
if t.State == "running" {
|
||||
if err := s.runtimeAddPeer(t, publicKey, clientAddress, preshared); err != nil {
|
||||
// wg set 可能只执行了部分操作;确保运行接口和持久配置都回到登记前。
|
||||
if _, cleanupErr := service.Run("wg", "set", interfaceName(t), "peer", publicKey, "remove"); cleanupErr != nil {
|
||||
return "", fmt.Errorf("运行时下发失败: %w;清理运行 Peer 失败: %v", err, cleanupErr)
|
||||
}
|
||||
if cleanupErr := s.removePersistedDevicePeer(t, id); cleanupErr != nil {
|
||||
return "", fmt.Errorf("运行时下发失败: %w;清理持久 Peer 失败: %v", err, cleanupErr)
|
||||
}
|
||||
return "", fmt.Errorf("运行时下发失败,已回滚设备登记: %w", err)
|
||||
}
|
||||
}
|
||||
return preshared, nil
|
||||
}
|
||||
|
||||
// RemoveDevicePeer 撤销设备 Peer:从配置与运行时同时移除。
|
||||
// 与管理端一致,不允许移除最后一个 Peer(否则隧道无法重启)。
|
||||
func (s *Service) RemoveDevicePeer(t *model.Tunnel, peerID string) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var removed *peer
|
||||
next := make([]peer, 0, len(peers))
|
||||
for i, p := range peers {
|
||||
if p.ID == peerID {
|
||||
removed = &peers[i]
|
||||
continue
|
||||
}
|
||||
next = append(next, p)
|
||||
}
|
||||
if removed == nil {
|
||||
return fmt.Errorf("Peer 不存在: %s", peerID)
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return fmt.Errorf("至少保留一个 Peer,无法移除;可先停用整条专线")
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 撤销先作用于运行中的接口:若失败,则不改磁盘配置或设备状态。
|
||||
if t.State == "running" {
|
||||
if _, err := service.Run("wg", "set", interfaceName(t), "peer", removed.PublicKey, "remove"); err != nil {
|
||||
return fmt.Errorf("运行中移除 Peer 失败,未更改持久配置: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := s.persistPeers(t, cfg, secrets, next); err != nil {
|
||||
if t.State == "running" {
|
||||
oldSecrets, _ := store.TunnelSecrets(t)
|
||||
oldPSK := peerSecretsForID(oldSecrets, peerID)
|
||||
if restoreErr := s.runtimeAddPeer(t, removed.PublicKey, removed.ClientAddress, oldPSK); restoreErr != nil {
|
||||
return fmt.Errorf("保存撤销配置失败: %w;恢复运行 Peer 也失败: %v", err, restoreErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func peerSecretsForID(secrets map[string]interface{}, id string) string {
|
||||
entries, _ := secrets["peer_secrets"].(map[string]interface{})
|
||||
entry, _ := entries[id].(map[string]interface{})
|
||||
psk, _ := entry["preshared_key"].(string)
|
||||
return psk
|
||||
}
|
||||
|
||||
// 登记出错后,仅移除已写入磁盘的设备,不要求再对不存在的运行接口操作。
|
||||
func (s *Service) removePersistedDevicePeer(t *model.Tunnel, id string) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next := make([]peer, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
if p.ID != id {
|
||||
next = append(next, p)
|
||||
}
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.persistPeers(t, cfg, secrets, next)
|
||||
return err
|
||||
}
|
||||
|
||||
// persistPeers 归一化 Peer 列表(补齐 PSK 等)、重写服务端 conf 并落库。
|
||||
// 与 Create 的持久化尾部一致,但不做端口/密钥初始化——调用前配置必须已初始化。
|
||||
// 返回归一化后的逐 Peer 密钥(含新生成的 PresharedKey)。
|
||||
func (s *Service) persistPeers(t *model.Tunnel, cfg, secrets map[string]interface{}, peers []peer) (map[string]peerSecret, error) {
|
||||
cfg["peers"] = peersToConfig(peers)
|
||||
normalized, peerSecrets, err := buildPeers(cfg, secrets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg["peers"] = peersToConfig(normalized)
|
||||
secrets["peer_secrets"] = peerSecretsToConfig(peerSecrets)
|
||||
|
||||
serverPrivate := service.Str(secrets, "server_private_key")
|
||||
if serverPrivate == "" {
|
||||
return nil, fmt.Errorf("专线尚未初始化服务端密钥,请先在管理端保存一次配置")
|
||||
}
|
||||
serverConf := renderServerConfig(cfg, serverPrivate, normalized, peerSecrets)
|
||||
confPath := InstanceConfPath(t)
|
||||
previous, readErr := os.ReadFile(confPath)
|
||||
previousConfig, previousSecrets := t.Config, t.Secrets
|
||||
if _, err := service.WriteConfig(t, interfaceName(t)+".conf", serverConf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "server_private_key")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
if err := service.SaveTunnelConfig(t, public, sec); err != nil {
|
||||
t.Config, t.Secrets = previousConfig, previousSecrets
|
||||
if readErr == nil {
|
||||
_ = os.WriteFile(confPath, previous, 0o600)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return peerSecrets, nil
|
||||
}
|
||||
|
||||
// runtimeAddPeer 用 wg set 把新 Peer 增量下发到运行中的接口,避免重启。
|
||||
func (s *Service) runtimeAddPeer(t *model.Tunnel, publicKey, clientAddress, presharedKey string) error {
|
||||
iface := interfaceName(t)
|
||||
args := []string{"set", iface, "peer", publicKey}
|
||||
var pskFile string
|
||||
if presharedKey != "" {
|
||||
dir := service.InstanceDir(t)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(dir, ".psk-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pskFile = f.Name()
|
||||
if _, err := f.WriteString(presharedKey); err != nil {
|
||||
f.Close()
|
||||
os.Remove(pskFile)
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
_ = os.Chmod(pskFile, 0o600)
|
||||
defer os.Remove(pskFile)
|
||||
args = append(args, "preshared-key", pskFile)
|
||||
}
|
||||
args = append(args, "allowed-ips", clientAddress)
|
||||
_, err := service.Run("wg", args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsedClientAddresses 返回服务端自身地址与全部 Peer 已占用的 IPv4/IPv6 地址。
|
||||
func (s *Service) UsedClientAddresses(t *model.Tunnel) (map[string]bool, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used := map[string]bool{}
|
||||
if ip, _, err := net.ParseCIDR(service.Str(cfg, "address")); err == nil && ip != nil {
|
||||
used[ip.String()] = true
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range peers {
|
||||
if ip, _, err := net.ParseCIDR(p.ClientAddress); err == nil && ip != nil {
|
||||
used[ip.String()] = true
|
||||
}
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// DeviceConnectionParams 返回下发给指定设备 Peer 的连接参数。
|
||||
// 不含任何客户端私钥(客户端私钥只存在于设备上),PSK 经客户端认证的
|
||||
// HTTPS 通道下发,用于抗量子的一次性对称加固。
|
||||
func (s *Service) DeviceConnectionParams(t *model.Tunnel, peerID string) (map[string]interface{}, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.ID != peerID {
|
||||
continue
|
||||
}
|
||||
allowed := p.AllowedIPs
|
||||
if allowed == "" {
|
||||
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"peer_id": p.ID,
|
||||
"client_address": p.ClientAddress,
|
||||
"server_public_key": service.Str(cfg, "server_public_key"),
|
||||
"preshared_key": parsePeerSecrets(secrets)[p.ID].PresharedKey,
|
||||
"endpoint": service.StrOr(cfg, "public_endpoint", fmt.Sprintf("your-server-ip:%d", int(service.Num(cfg, "listen_port", defaultPort)))),
|
||||
"listen_port": int(service.Num(cfg, "listen_port", defaultPort)),
|
||||
"allowed_ips": allowed,
|
||||
"dns": service.StrOr(cfg, "dns", service.DefaultDNS()),
|
||||
"mtu": int(service.Num(cfg, "mtu", 0)),
|
||||
"keepalive": int(service.Num(cfg, "keepalive", 25)),
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("设备 Peer 不存在: %s", peerID)
|
||||
}
|
||||
|
||||
// InstanceConfPath 暴露实例服务端配置路径(测试与诊断用)。
|
||||
func InstanceConfPath(t *model.Tunnel) string {
|
||||
return filepath.Join(service.InstanceDir(t), interfaceName(t)+".conf")
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func setupDeviceTestTunnel(t *testing.T) *model.Tunnel {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
t.Setenv("VPN_DATA_DIR", base)
|
||||
if err := store.Init(base); err != nil {
|
||||
t.Fatalf("store.Init: %v", err)
|
||||
}
|
||||
tun := &model.Tunnel{
|
||||
Name: "客户端测试专线",
|
||||
Protocol: "wireguard",
|
||||
State: "stopped",
|
||||
Config: `{"address":"10.9.0.1/24","listen_port":53999}`,
|
||||
}
|
||||
if err := store.DB.Create(tun).Error; err != nil {
|
||||
t.Fatalf("create tunnel: %v", err)
|
||||
}
|
||||
if err := New().Create(tun); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
return tun
|
||||
}
|
||||
|
||||
func TestRegisterAndRemoveDevicePeer(t *testing.T) {
|
||||
tun := setupDeviceTestTunnel(t)
|
||||
svc := New()
|
||||
|
||||
// 初始 Create 会把无 peers 的旧配置迁移出 client-1 托管 Peer(默认 10.8.0.2/32)
|
||||
used, err := svc.UsedClientAddresses(tun)
|
||||
if err != nil {
|
||||
t.Fatalf("UsedClientAddresses: %v", err)
|
||||
}
|
||||
if !used["10.9.0.1"] || !used["10.8.0.2"] {
|
||||
t.Fatalf("应包含服务端与初始 Peer 地址: %v", used)
|
||||
}
|
||||
|
||||
priv, pub, err := genKey()
|
||||
if err != nil || priv == "" {
|
||||
t.Fatalf("genKey: %v", err)
|
||||
}
|
||||
psk, err := svc.RegisterDevicePeer(tun, "dev-1", "测试手机", pub, "10.9.0.7/32")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterDevicePeer: %v", err)
|
||||
}
|
||||
if !isWireGuardKey(psk) {
|
||||
t.Fatal("应为设备 Peer 生成合法 PresharedKey")
|
||||
}
|
||||
|
||||
params, err := svc.DeviceConnectionParams(tun, "dev-1")
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceConnectionParams: %v", err)
|
||||
}
|
||||
if params["client_address"] != "10.9.0.7/32" || params["preshared_key"] != psk {
|
||||
t.Fatalf("连接参数不含分配地址或 PSK 不匹配: %v", params)
|
||||
}
|
||||
if params["server_public_key"] == "" || params["endpoint"] == "" {
|
||||
t.Fatalf("连接参数缺少服务端公钥或端点: %v", params)
|
||||
}
|
||||
|
||||
// 重复公钥或重复地址必须被拒绝
|
||||
if _, err := svc.RegisterDevicePeer(tun, "dev-2", "重复密钥", pub, "10.9.0.8/32"); err == nil {
|
||||
t.Fatal("重复公钥必须被拒绝")
|
||||
}
|
||||
_, pub2, _ := genKey()
|
||||
if _, err := svc.RegisterDevicePeer(tun, "dev-3", "重复地址", pub2, "10.9.0.7/32"); err == nil {
|
||||
t.Fatal("重复地址必须被拒绝")
|
||||
}
|
||||
|
||||
// 服务端 conf 必须包含新 Peer
|
||||
conf := readFile(t, InstanceConfPath(tun))
|
||||
if !strings.Contains(conf, pub) {
|
||||
t.Fatal("服务端配置未包含设备公钥")
|
||||
}
|
||||
|
||||
if err := svc.RemoveDevicePeer(tun, "dev-1"); err != nil {
|
||||
t.Fatalf("RemoveDevicePeer: %v", err)
|
||||
}
|
||||
if _, err := svc.DeviceConnectionParams(tun, "dev-1"); err == nil {
|
||||
t.Fatal("撤销后不应再能取到连接参数")
|
||||
}
|
||||
conf = readFile(t, InstanceConfPath(tun))
|
||||
if strings.Contains(conf, pub) {
|
||||
t.Fatal("撤销后服务端配置仍包含设备公钥")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDevicePeerKeepsLastPeer(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
t.Setenv("VPN_DATA_DIR", base)
|
||||
if err := store.Init(base); err != nil {
|
||||
t.Fatalf("store.Init: %v", err)
|
||||
}
|
||||
// 没有任何既有 Peer 的隧道:登记一个设备后它就是唯一 Peer
|
||||
tun := &model.Tunnel{
|
||||
Name: "空专线", Protocol: "wireguard", State: "stopped",
|
||||
Config: `{"address":"10.10.0.1/24","listen_port":53998,"peers":[]}`,
|
||||
}
|
||||
if err := store.DB.Create(tun).Error; err != nil {
|
||||
t.Fatalf("create tunnel: %v", err)
|
||||
}
|
||||
// peers 为空数组时 Create 会拒绝;直接手工初始化密钥再登记设备
|
||||
tun.Config = `{"address":"10.10.0.1/24","listen_port":53998,"peers":[{"id":"seed","name":"种子","public_key":"","client_address":"10.10.0.2/32"}]}`
|
||||
if err := New().Create(tun); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
svc := New()
|
||||
if err := svc.RemoveDevicePeer(tun, "seed"); err == nil {
|
||||
t.Fatal("不允许移除最后一个 Peer")
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
Reference in new issue
Block a user