621e8895c9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.4 KiB
Go
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
|
|
}
|