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 }