diff --git a/README.md b/README.md index d5d2494..5d4c21b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,35 @@ a device as a ZFS vdev. It is intended for decommissioning or repurposing a device after it has been cleanly removed from its pool. +## Unified Eraser + +`erase-zfs.sh` is the recommended entry point. It performs a full-device wipe +by default and uses 10 overwrite passes. Select `--metadata` when only the +partition tables and ZFS vdev labels should be overwritten. + +```bash +chmod +x erase-zfs.sh +sudo ./erase-zfs.sh /dev/sdX +sudo ./erase-zfs.sh --metadata /dev/sdX +sudo ./erase-zfs.sh --passes 3 /dev/sdX +sudo ./erase-zfs.sh --source random --verify /dev/sdX +``` + +`--wipe` explicitly selects the default full-device mode. `--source zeroes` +is the default, while `--source random` uses `/dev/urandom`. `--passes` accepts +a positive integer and defaults to 10. `--verify` runs after wiping and checks +`wipefs` signatures, readable ZFS labels through `zdb -l`, and ZFS pool +discovery. A full zero wipe additionally compares every readable byte against +`/dev/zero`. Random data cannot be byte-for-byte checked after it is written, +so its verification is limited to the metadata and ZFS recovery checks. The +verifier marks unavailable or ambiguous ZFS discovery as a failure rather than +declaring the wipe successful. + +Pool discovery searches the target device's parent directory. Any reported +pool may use a neighboring device, so inspect its vdev paths before treating +the result as evidence about the erased target. A failed verification exits +with a nonzero status and leaves all evidence in place for investigation. + ## What It Erases The script performs 10 overwrite passes over the first 4 MiB and the last diff --git a/erase-zfs.sh b/erase-zfs.sh new file mode 100644 index 0000000..182659d --- /dev/null +++ b/erase-zfs.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Fully erase a device or erase its partition-table and ZFS metadata. + +set -euo pipefail + +readonly DEFAULT_PASSES=10 +readonly MIB=$((1024 * 1024)) +readonly METADATA_MIB=4 +readonly METADATA_BYTES=$((METADATA_MIB * MIB)) + +usage() { + cat <<'EOF' +Usage: sudo ./erase-zfs.sh [--wipe|--metadata] [--source zeroes|random] [--passes ] [--verify] /dev/ + +Fully overwrites the target device ten times by default. --metadata overwrites +only the first and last 4 MiB, covering partition tables and ZFS vdev labels. + +Options: + --wipe Fully overwrite the device (default). + --metadata Overwrite only partition-table and ZFS metadata regions. + --source Write zeroes (default) or random data. + --passes Use a positive number of overwrite passes (default: 10). + --verify Run signature, ZFS-label, and pool-discovery checks after wiping. + -h, --help Show this help message. +EOF +} + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +wipe_mode=wipe +write_source=/dev/zero +source_name=zeroes +passes=$DEFAULT_PASSES +verify=false +device= + +while [[ $# -gt 0 ]]; do + case $1 in + --wipe) + wipe_mode=wipe + shift + ;; + --metadata) + wipe_mode=metadata + shift + ;; + --source) + [[ $# -ge 2 ]] || die '--source requires zeroes or random.' + case $2 in + zeroes) + write_source=/dev/zero + source_name=zeroes + ;; + random) + write_source=/dev/urandom + source_name=random + ;; + *) die "invalid source '$2'; use zeroes or random." ;; + esac + shift 2 + ;; + --passes) + [[ $# -ge 2 ]] || die '--passes requires a positive integer.' + [[ $2 =~ ^[1-9][0-9]*$ ]] || die "invalid pass count '$2'; use a positive integer." + passes=$2 + shift 2 + ;; + --verify) + verify=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) die "unknown option '$1'." ;; + *) + [[ -z $device ]] || die 'specify exactly one block device.' + device=$1 + shift + ;; + esac +done + +[[ -n $device ]] || { usage >&2; exit 2; } +[[ $EUID -eq 0 ]] || die 'run as root (for example, with sudo).' +[[ -b $device ]] || die "'$device' is not a block device." +[[ -r $write_source ]] || die "'$write_source' is not readable." + +for command in blockdev dd findmnt lsblk zpool; do + command -v "$command" >/dev/null 2>&1 || die "required command '$command' was not found." +done + +if [[ $verify == true ]]; then + for command in wipefs zdb; do + command -v "$command" >/dev/null 2>&1 || die "--verify requires '$command'." + done + if [[ $source_name == zeroes ]]; then + command -v cmp >/dev/null 2>&1 || die "--verify with zeroes requires 'cmp'." + fi +fi + +if findmnt --noheadings --source "$device" >/dev/null 2>&1; then + die "'$device' is mounted; unmount it before continuing." +fi + +if lsblk --noheadings --raw --output MOUNTPOINT "$device" | grep -q '[^[:space:]]'; then + die "'$device' or one of its children is mounted; unmount it before continuing." +fi + +if zpool status -P 2>/dev/null | grep -Fq -- "$device"; then + die "'$device' belongs to an imported ZFS pool; export or detach it before continuing." +fi + +size_bytes=$(blockdev --getsize64 "$device") +(( size_bytes > 0 )) || die "'$device' has no addressable capacity." +if [[ $wipe_mode == metadata ]]; then + (( size_bytes >= 2 * METADATA_BYTES )) || die "'$device' is smaller than $((2 * METADATA_MIB)) MiB." +fi + +printf '\nWARNING: this permanently performs a %s wipe on:\n %s\nusing %s data for %d passes.\n\n' \ + "$wipe_mode" "$device" "$source_name" "$passes" +lsblk --output NAME,SIZE,TYPE,MOUNTPOINTS "$device" +read -r -p "Type exactly 'ERASE $device' to continue: " confirmation +[[ $confirmation == "ERASE $device" ]] || die 'confirmation did not match; no data was changed.' + +wipe_metadata() { + local last_offset=$((size_bytes - METADATA_BYTES)) + dd if="$write_source" of="$device" bs="$MIB" count="$METADATA_MIB" iflag=fullblock conv=fsync,notrunc status=none + dd if="$write_source" of="$device" bs=1 count="$METADATA_BYTES" seek="$last_offset" iflag=fullblock conv=fsync,notrunc status=none +} + +wipe_full_device() { + local whole_mib=$((size_bytes / MIB)) + local remainder=$((size_bytes % MIB)) + + if (( whole_mib > 0 )); then + dd if="$write_source" of="$device" bs="$MIB" count="$whole_mib" iflag=fullblock conv=fsync,notrunc status=progress + fi + if (( remainder > 0 )); then + dd if="$write_source" of="$device" bs=1 count="$remainder" seek=$((whole_mib * MIB)) iflag=fullblock conv=fsync,notrunc status=none + fi +} + +for ((pass = 1; pass <= passes; pass++)); do + printf 'Pass %d/%d...\n' "$pass" "$passes" + if [[ $wipe_mode == wipe ]]; then + wipe_full_device + else + wipe_metadata + fi +done +sync +printf 'Completed %d %s %s passes on %s.\n' "$passes" "$source_name" "$wipe_mode" "$device" + +[[ $verify == true ]] || exit 0 + +verification_failed=false +printf '\nVerification: checking for remaining signatures.\n' +if wipefs --all --no-act "$device"; then + printf 'PASS: wipefs found no signatures.\n' +else + printf 'FAIL: wipefs reported one or more signatures.\n' >&2 + verification_failed=true +fi + +printf '\nVerification: checking for readable ZFS labels.\n' +if zdb -l "$device"; then + printf 'FAIL: zdb found a readable ZFS label.\n' >&2 + verification_failed=true +else + printf 'PASS: zdb found no readable ZFS label.\n' +fi + +printf '\nVerification: checking whether ZFS discovers an importable pool near this device.\n' +discovery_output= +if ! discovery_output=$(zpool import -d "$(dirname -- "$device")" 2>&1); then + : +fi +if grep -Eq '^[[:space:]]*pool:[[:space:]]' <<<"$discovery_output"; then + printf '%s\n' "$discovery_output" + printf 'FAIL: zpool found an importable pool, possibly using this device.\n' >&2 + verification_failed=true +elif grep -Fq 'no pools available to import' <<<"$discovery_output"; then + printf 'PASS: zpool found no importable pools in the device directory.\n' +else + printf '%s\n' "$discovery_output" >&2 + printf 'FAIL: zpool discovery did not produce a conclusive result.\n' >&2 + verification_failed=true +fi + +if [[ $wipe_mode == wipe && $source_name == zeroes ]]; then + printf '\nVerification: comparing the full device with zeroes.\n' + if cmp --silent /dev/zero "$device"; then + printf 'PASS: the complete readable device contains zeroes.\n' + else + printf 'FAIL: the complete readable device is not all zeroes.\n' >&2 + verification_failed=true + fi +fi + +[[ $verification_failed == false ]] || exit 1 +printf '\nVerification completed successfully.\n' \ No newline at end of file