621e8895c9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
409 lines
11 KiB
Go
409 lines
11 KiB
Go
package infra
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// WGPeerAdd añade un peer WireGuard al wg0.conf del hub y aplica la config
|
|
// en caliente con `wg syncconf` sin reiniciar la interface.
|
|
//
|
|
// Idempotente:
|
|
// - Si PublicKey ya está presente con la misma config → "already-present".
|
|
// - Si DeviceID existe con otra PublicKey → reemplaza el bloque → "reconfigured".
|
|
// - Si AllowedIPs está vacío, asigna la primera IP libre de subnetCIDR (excluyendo .1).
|
|
//
|
|
// Escribe atómicamente (tmpfile + rename) y hace chmod 600 sobre configPath.
|
|
// Si syncconf falla, restaura el backup y devuelve error.
|
|
//
|
|
// Para tests CI sin WireGuard real, establecer WG_SKIP_SYNCCONF=1.
|
|
func WGPeerAdd(spec WGPeerSpec, configPath string, subnetCIDR string) (WGPeerResult, error) {
|
|
// --- leer config actual ---
|
|
existing, err := os.ReadFile(configPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: read config: %w", err)
|
|
}
|
|
content := string(existing)
|
|
|
|
// --- parsear peers existentes ---
|
|
peers, err := wgParsePeers(content)
|
|
if err != nil {
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: parse peers: %w", err)
|
|
}
|
|
|
|
// --- idempotencia: buscar peer existente ANTES de asignar IP ---
|
|
status := "added"
|
|
existingBlock, foundByKey := peers[spec.PublicKey]
|
|
_, foundByDevice := wgFindByDeviceID(peers, spec.DeviceID)
|
|
|
|
// --- determinar AllowedIPs ---
|
|
allowedIPs := spec.AllowedIPs
|
|
if allowedIPs == "" {
|
|
if foundByKey && existingBlock.allowedIPs != "" {
|
|
// reusar la IP del peer existente para idempotencia
|
|
allowedIPs = existingBlock.allowedIPs
|
|
} else {
|
|
ip, err := wgNextFreeIP(subnetCIDR, peers)
|
|
if err != nil {
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: assign ip: %w", err)
|
|
}
|
|
allowedIPs = ip + "/32"
|
|
}
|
|
}
|
|
|
|
// extraer IP pura para el resultado
|
|
assignedIP := allowedIPs
|
|
if idx := strings.Index(allowedIPs, "/"); idx >= 0 {
|
|
assignedIP = allowedIPs[:idx]
|
|
}
|
|
|
|
if foundByKey {
|
|
// misma PublicKey ya está — verificar si la config coincide
|
|
if existingBlock.allowedIPs == allowedIPs &&
|
|
existingBlock.presharedKey == spec.PresharedKey {
|
|
return WGPeerResult{
|
|
DeviceID: spec.DeviceID,
|
|
AssignedIP: assignedIP,
|
|
ConfigPath: configPath,
|
|
Status: "already-present",
|
|
}, nil
|
|
}
|
|
status = "reconfigured"
|
|
} else if foundByDevice {
|
|
// DeviceID existe con otra PublicKey → reconfigured (replace)
|
|
status = "reconfigured"
|
|
}
|
|
|
|
// --- construir nueva config ---
|
|
newContent, err := wgRebuildConfig(content, spec, allowedIPs, status)
|
|
if err != nil {
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: rebuild config: %w", err)
|
|
}
|
|
|
|
// --- backup ---
|
|
backupPath := configPath + ".bak"
|
|
if len(existing) > 0 {
|
|
if err := os.WriteFile(backupPath, existing, 0600); err != nil {
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: backup: %w", err)
|
|
}
|
|
}
|
|
|
|
// --- escritura atómica ---
|
|
tmpPath := configPath + ".tmp"
|
|
if err := os.WriteFile(tmpPath, []byte(newContent), 0600); err != nil {
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: write tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, configPath); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: rename: %w", err)
|
|
}
|
|
_ = os.Chmod(configPath, 0600)
|
|
|
|
// --- syncconf ---
|
|
iface := wgIfaceFromPath(configPath)
|
|
if iface != "" {
|
|
if err := wgSyncConfFn(iface, configPath); err != nil {
|
|
// restaurar backup
|
|
if len(existing) > 0 {
|
|
_ = os.WriteFile(configPath, existing, 0600)
|
|
}
|
|
return WGPeerResult{}, fmt.Errorf("wg_peer_add: syncconf: %w", err)
|
|
}
|
|
}
|
|
|
|
return WGPeerResult{
|
|
DeviceID: spec.DeviceID,
|
|
AssignedIP: assignedIP,
|
|
ConfigPath: configPath,
|
|
Status: status,
|
|
}, nil
|
|
}
|
|
|
|
// --- helpers internos ---
|
|
|
|
type wgPeerBlock struct {
|
|
deviceID string
|
|
publicKey string
|
|
presharedKey string
|
|
allowedIPs string
|
|
rawLines []string // líneas originales del bloque incluyendo comentario # DeviceID
|
|
}
|
|
|
|
// wgParsePeers extrae todos los bloques [Peer] indexados por PublicKey.
|
|
func wgParsePeers(content string) (map[string]*wgPeerBlock, error) {
|
|
peers := map[string]*wgPeerBlock{}
|
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
|
|
|
var cur *wgPeerBlock
|
|
var pendingDeviceID string
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// comentario de tracking: # DeviceID: <id>
|
|
if strings.HasPrefix(trimmed, "# DeviceID:") {
|
|
pendingDeviceID = strings.TrimSpace(strings.TrimPrefix(trimmed, "# DeviceID:"))
|
|
if cur != nil {
|
|
cur.rawLines = append(cur.rawLines, line)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if trimmed == "[Peer]" {
|
|
cur = &wgPeerBlock{deviceID: pendingDeviceID}
|
|
cur.rawLines = append(cur.rawLines, line)
|
|
pendingDeviceID = ""
|
|
continue
|
|
}
|
|
|
|
if cur != nil {
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "[") {
|
|
// fin del bloque
|
|
if cur.publicKey != "" {
|
|
peers[cur.publicKey] = cur
|
|
}
|
|
cur = nil
|
|
if strings.HasPrefix(trimmed, "[") {
|
|
// nueva sección, no es [Peer]
|
|
}
|
|
continue
|
|
}
|
|
cur.rawLines = append(cur.rawLines, line)
|
|
if k, v, ok := wgKV(trimmed); ok {
|
|
switch k {
|
|
case "PublicKey":
|
|
cur.publicKey = v
|
|
case "PresharedKey":
|
|
cur.presharedKey = v
|
|
case "AllowedIPs":
|
|
cur.allowedIPs = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if cur != nil && cur.publicKey != "" {
|
|
peers[cur.publicKey] = cur
|
|
}
|
|
return peers, scanner.Err()
|
|
}
|
|
|
|
// wgFindByDeviceID busca un peer por DeviceID.
|
|
func wgFindByDeviceID(peers map[string]*wgPeerBlock, deviceID string) (*wgPeerBlock, bool) {
|
|
for _, p := range peers {
|
|
if p.deviceID == deviceID {
|
|
return p, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// wgNextFreeIP encuentra la primera IP libre en subnetCIDR (excluyendo .1 del hub).
|
|
func wgNextFreeIP(subnetCIDR string, peers map[string]*wgPeerBlock) (string, error) {
|
|
ip, ipNet, err := net.ParseCIDR(subnetCIDR)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid subnetCIDR %q: %w", subnetCIDR, err)
|
|
}
|
|
_ = ip
|
|
|
|
// recopilar IPs ya usadas
|
|
used := map[string]bool{}
|
|
for _, p := range peers {
|
|
cidr := p.allowedIPs
|
|
if idx := strings.Index(cidr, "/"); idx >= 0 {
|
|
used[cidr[:idx]] = true
|
|
}
|
|
}
|
|
|
|
// iterar desde .2 (hub es .1)
|
|
for candidate := wgIncrIP(ipNet.IP); ipNet.Contains(candidate); candidate = wgIncrIP(candidate) {
|
|
s := candidate.String()
|
|
// saltar la dirección de red (.0) y broadcast
|
|
if s == ipNet.IP.String() {
|
|
continue
|
|
}
|
|
// saltar hub (.1)
|
|
hubIP := wgIncrIP(ipNet.IP)
|
|
if s == hubIP.String() {
|
|
// esta es .1 (hub), saltar
|
|
continue
|
|
}
|
|
if !used[s] {
|
|
return s, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no free IPs in %s", subnetCIDR)
|
|
}
|
|
|
|
// wgIncrIP incrementa una IP en 1.
|
|
func wgIncrIP(ip net.IP) net.IP {
|
|
ip = ip.To4()
|
|
if ip == nil {
|
|
return nil
|
|
}
|
|
result := make(net.IP, 4)
|
|
copy(result, ip)
|
|
for i := 3; i >= 0; i-- {
|
|
result[i]++
|
|
if result[i] != 0 {
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// wgRebuildConfig reconstruye el contenido del config con el peer añadido/reemplazado.
|
|
func wgRebuildConfig(content string, spec WGPeerSpec, allowedIPs, status string) (string, error) {
|
|
// construir nuevo bloque
|
|
var newBlock strings.Builder
|
|
newBlock.WriteString(fmt.Sprintf("# DeviceID: %s\n", spec.DeviceID))
|
|
newBlock.WriteString("[Peer]\n")
|
|
newBlock.WriteString(fmt.Sprintf("PublicKey = %s\n", spec.PublicKey))
|
|
if spec.PresharedKey != "" {
|
|
newBlock.WriteString(fmt.Sprintf("PresharedKey = %s\n", spec.PresharedKey))
|
|
}
|
|
newBlock.WriteString(fmt.Sprintf("AllowedIPs = %s\n", allowedIPs))
|
|
|
|
if status == "added" {
|
|
// simplemente añadir al final
|
|
result := content
|
|
if !strings.HasSuffix(result, "\n") && len(result) > 0 {
|
|
result += "\n"
|
|
}
|
|
result += "\n" + newBlock.String()
|
|
return result, nil
|
|
}
|
|
|
|
// reconfigured: reemplazar el bloque existente (buscar por DeviceID o PublicKey)
|
|
result, err := wgReplaceBlock(content, spec, newBlock.String())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// wgReplaceBlock reemplaza el bloque del peer identificado por DeviceID o PublicKey.
|
|
// Estrategia: parsear el config en segmentos (pre-bloque / bloque-target / post-bloque)
|
|
// y reconstruir sustituyendo solo el bloque target.
|
|
func wgReplaceBlock(content string, spec WGPeerSpec, newBlock string) (string, error) {
|
|
type segment struct {
|
|
isPeer bool
|
|
lines []string // incluye comentario # DeviceID si lo había
|
|
pk string
|
|
did string
|
|
}
|
|
|
|
var segments []segment
|
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
|
|
|
var cur *segment
|
|
var pendingComment string
|
|
|
|
flush := func() {
|
|
if cur != nil {
|
|
segments = append(segments, *cur)
|
|
cur = nil
|
|
}
|
|
}
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if strings.HasPrefix(trimmed, "# DeviceID:") {
|
|
pendingComment = line
|
|
continue
|
|
}
|
|
|
|
if trimmed == "[Peer]" {
|
|
flush()
|
|
seg := segment{isPeer: true}
|
|
if pendingComment != "" {
|
|
seg.lines = append(seg.lines, pendingComment)
|
|
seg.did = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(pendingComment), "# DeviceID:"))
|
|
pendingComment = ""
|
|
}
|
|
seg.lines = append(seg.lines, line)
|
|
cur = &seg
|
|
continue
|
|
}
|
|
|
|
if cur != nil && cur.isPeer {
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "[") {
|
|
flush()
|
|
if pendingComment != "" {
|
|
segments = append(segments, segment{lines: []string{pendingComment}})
|
|
pendingComment = ""
|
|
}
|
|
if trimmed != "" {
|
|
cur = &segment{lines: []string{line}}
|
|
}
|
|
continue
|
|
}
|
|
cur.lines = append(cur.lines, line)
|
|
if k, v, ok := wgKV(trimmed); ok && k == "PublicKey" {
|
|
cur.pk = v
|
|
}
|
|
continue
|
|
}
|
|
|
|
// línea fuera de bloque peer
|
|
if pendingComment != "" {
|
|
if cur == nil {
|
|
cur = &segment{}
|
|
}
|
|
cur.lines = append(cur.lines, pendingComment)
|
|
pendingComment = ""
|
|
}
|
|
if cur == nil {
|
|
cur = &segment{}
|
|
}
|
|
cur.lines = append(cur.lines, line)
|
|
}
|
|
flush()
|
|
if err := scanner.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// reconstruir sustituyendo el target
|
|
var out strings.Builder
|
|
replaced := false
|
|
for _, seg := range segments {
|
|
if seg.isPeer && !replaced {
|
|
isTarget := (seg.pk == spec.PublicKey) ||
|
|
(spec.DeviceID != "" && seg.did == spec.DeviceID)
|
|
if isTarget {
|
|
out.WriteString("\n" + newBlock)
|
|
replaced = true
|
|
continue
|
|
}
|
|
}
|
|
for _, l := range seg.lines {
|
|
out.WriteString(l + "\n")
|
|
}
|
|
}
|
|
|
|
if !replaced {
|
|
result := out.String()
|
|
if !strings.HasSuffix(result, "\n") && len(result) > 0 {
|
|
result += "\n"
|
|
}
|
|
result += "\n" + newBlock
|
|
return result, nil
|
|
}
|
|
|
|
return out.String(), nil
|
|
}
|
|
|
|
// wgKV parsea "Key = Value" devolviendo (key, value, true) o ("", "", false).
|
|
func wgKV(line string) (string, string, bool) {
|
|
idx := strings.IndexByte(line, '=')
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]), true
|
|
}
|