feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user