Files
fn_registry/functions/infra/wg_peer_revoke.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

158 lines
5.4 KiB
Go

package infra
import (
"crypto/sha256"
"database/sql"
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migrations/wg_revoked/001_revoked_peers.sql
var wgRevokedMigration string
// WGPeerRevokeAudit contiene el registro inmutable de una revocacion.
type WGPeerRevokeAudit struct {
DeviceID string
PublicKey string
RevokedAt int64
RevokedBy string
Reason string
PrevHash string
ThisHash string
}
// WGPeerRevoke revoca permanentemente un peer: lo elimina del config activo,
// lo registra en una audit DB con hash chain SHA256 inviolable y escribe en
// la blacklist persistente /etc/wireguard/wg_revoked.list.
//
// Reglas:
// - reason no puede estar vacio.
// - Revocar un peer ya revocado devuelve error "already revoked".
// - auditDBPath se crea con migracion embebida si no existe.
// - this_hash = SHA256(prev_hash || device_id || public_key || revoked_at || revoked_by || reason)
func WGPeerRevoke(deviceID, operator, reason string, configPath, auditDBPath string) (WGPeerRevokeAudit, error) {
audit := WGPeerRevokeAudit{
DeviceID: deviceID,
RevokedBy: operator,
Reason: reason,
}
if strings.TrimSpace(deviceID) == "" {
return audit, fmt.Errorf("wg_peer_revoke: deviceID cannot be empty")
}
if strings.TrimSpace(operator) == "" {
return audit, fmt.Errorf("wg_peer_revoke: operator cannot be empty")
}
if strings.TrimSpace(reason) == "" {
return audit, fmt.Errorf("wg_peer_revoke: reason cannot be empty")
}
if strings.TrimSpace(configPath) == "" {
return audit, fmt.Errorf("wg_peer_revoke: configPath cannot be empty")
}
if strings.TrimSpace(auditDBPath) == "" {
return audit, fmt.Errorf("wg_peer_revoke: auditDBPath cannot be empty")
}
// 1. Abrir/crear audit DB y aplicar migracion.
if err := os.MkdirAll(filepath.Dir(auditDBPath), 0700); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: mkdir audit db dir: %w", err)
}
db, err := sql.Open("sqlite3", auditDBPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: open audit db: %w", err)
}
defer db.Close()
if _, err := db.Exec(wgRevokedMigration); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: apply migration: %w", err)
}
// 2. Verificar que no este ya revocado ANTES del lookup (el peer puede
// haber sido eliminado del config por una revocacion previa).
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM revoked_peers WHERE device_id = ?", deviceID).Scan(&count); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: check existing: %w", err)
}
if count > 0 {
return audit, fmt.Errorf("wg_peer_revoke: device %s already revoked", deviceID)
}
// 3. Lookup PublicKey (el peer debe estar aun en el config en este punto).
pubKey, err := wgLookupPeerPublicKey(deviceID, configPath)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: lookup peer: %w", err)
}
audit.PublicKey = pubKey
// 4. Obtener prev_hash (hash del ultimo registro, o string vacio si genesis).
var prevHash string
_ = db.QueryRow("SELECT this_hash FROM revoked_peers ORDER BY revoked_at DESC LIMIT 1").Scan(&prevHash)
// 5. Calcular this_hash = SHA256(prevHash || deviceID || publicKey || revokedAt || operator || reason).
revokedAt := time.Now().Unix()
audit.RevokedAt = revokedAt
audit.PrevHash = prevHash
hashInput := fmt.Sprintf("%s|%s|%s|%d|%s|%s", prevHash, deviceID, pubKey, revokedAt, operator, reason)
thisHash := fmt.Sprintf("%x", sha256.Sum256([]byte(hashInput)))
audit.ThisHash = thisHash
// 6. WGPeerRemove (eliminar del config + syncconf).
removeResult, err := WGPeerRemove(deviceID, configPath)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: remove peer: %w", err)
}
_ = removeResult // status puede ser removed o not-present (ya borrado, igual revocamos)
// 7. Insertar en audit DB dentro de una transaccion.
tx, err := db.Begin()
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(
`INSERT INTO revoked_peers (device_id, public_key, revoked_at, revoked_by, reason, prev_hash, this_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
deviceID, pubKey, revokedAt, operator, reason, prevHash, thisHash,
)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: insert audit record: %w", err)
}
if err := tx.Commit(); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: commit audit record: %w", err)
}
// 8. Append a blacklist persistente /etc/wireguard/wg_revoked.list.
blacklistLine := fmt.Sprintf("%s %d # DeviceID:%s operator:%s reason:%s\n",
pubKey, revokedAt, deviceID, operator, reason)
if err := wgAppendBlacklistFn(blacklistLine); err != nil {
// No revertir — el audit DB ya tiene el registro. Loguear como advertencia.
return audit, fmt.Errorf("wg_peer_revoke: append blacklist (audit DB committed): %w", err)
}
return audit, nil
}
// wgAppendBlacklistFn escribe una linea al final de /etc/wireguard/wg_revoked.list (append-only).
// Variable para permitir override en tests sin requerir permisos de /etc/wireguard/.
var wgAppendBlacklistFn = func(line string) error {
const blacklistPath = "/etc/wireguard/wg_revoked.list"
f, err := os.OpenFile(blacklistPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("open blacklist %s: %w", blacklistPath, err)
}
defer f.Close()
if _, err := f.WriteString(line); err != nil {
return fmt.Errorf("write blacklist: %w", err)
}
return nil
}