Files
fn_registry/functions/infra/wg_peer_add.go
T
egutierrez 621e8895c9 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00

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
}