Initial remote commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
inodes.db
|
||||
BIN
__pycache__/ext4db.cpython-312.pyc
Normal file
BIN
__pycache__/ext4db.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ext4lib.cpython-312.pyc
Normal file
BIN
__pycache__/ext4lib.cpython-312.pyc
Normal file
Binary file not shown.
33
batch_recover.sh
Executable file
33
batch_recover.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch-extract all orphan roots from orphan_roots.txt
|
||||
# Runs up to JOBS parallel extract_tree.py processes.
|
||||
# Usage: ./batch_recover.sh [--restore-meta] [--include-deleted]
|
||||
set -euo pipefail
|
||||
|
||||
DEST="/mnt/recovered/apr30"
|
||||
DB="inodes.db"
|
||||
INPUT="orphan_roots.txt"
|
||||
JOBS=4
|
||||
EXTRA_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
EXTRA_ARGS+=("$arg")
|
||||
done
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
while read -r inum rest; do
|
||||
[[ -z "$inum" || "$inum" == \#* ]] && continue
|
||||
python3 extract_tree.py \
|
||||
--db "$DB" \
|
||||
--dest "$DEST" \
|
||||
--skip-existing \
|
||||
"${EXTRA_ARGS[@]}" \
|
||||
"$inum" &
|
||||
while (( $(jobs -r | wc -l) >= JOBS )); do
|
||||
wait -n 2>/dev/null || sleep 0.2
|
||||
done
|
||||
done < "$INPUT"
|
||||
|
||||
wait
|
||||
echo "All done"
|
||||
26
batch_recover_resume.sh
Executable file
26
batch_recover_resume.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resume a batch extraction — skips inodes whose destination already exists.
|
||||
# Equivalent to batch_recover.sh --skip-existing but kept separate for clarity.
|
||||
set -euo pipefail
|
||||
|
||||
DEST="/mnt/recovered/apr30"
|
||||
DB="inodes.db"
|
||||
INPUT="orphan_roots.txt"
|
||||
JOBS=10
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
while read -r inum rest; do
|
||||
[[ -z "$inum" || "$inum" == \#* ]] && continue
|
||||
python3 extract_tree.py \
|
||||
--db "$DB" \
|
||||
--dest "$DEST" \
|
||||
--skip-existing \
|
||||
"$inum" &
|
||||
while (( $(jobs -r | wc -l) >= JOBS )); do
|
||||
wait -n 2>/dev/null || sleep 0.2
|
||||
done
|
||||
done < "$INPUT"
|
||||
|
||||
wait
|
||||
echo "All done"
|
||||
21
batch_restore.sh
Executable file
21
batch_restore.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch-restore metadata for already-extracted trees.
|
||||
# Usage: ./batch_restore.sh
|
||||
set -euo pipefail
|
||||
|
||||
DEST="/mnt/recovered/apr30"
|
||||
INPUT="orphan_roots.txt"
|
||||
JOBS=10
|
||||
|
||||
while read -r inum rest; do
|
||||
[[ -z "$inum" || "$inum" == \#* ]] && continue
|
||||
dest="${DEST}/${inum}"
|
||||
[[ -d "$dest" ]] || continue
|
||||
python3 restore_meta.py "$inum" "$dest" &
|
||||
while (( $(jobs -r | wc -l) >= JOBS )); do
|
||||
wait -n 2>/dev/null || sleep 0.2
|
||||
done
|
||||
done < "$INPUT"
|
||||
|
||||
wait
|
||||
echo "All done"
|
||||
169
diagnose_inode.py
Normal file
169
diagnose_inode.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnose why a file inode extracted as empty (or wrong size).
|
||||
|
||||
For each inode given, reports:
|
||||
- size recorded in the inode
|
||||
- flags (inline-data, extents, etc.)
|
||||
- extent tree validity
|
||||
- whether data blocks fall in the zeroed region
|
||||
- actual bytes readable from each block
|
||||
|
||||
Usage:
|
||||
python3 diagnose_inode.py <inode> [<inode> ...]
|
||||
python3 diagnose_inode.py --dir <dir_inode> # diagnose all files in a dir
|
||||
|
||||
Options:
|
||||
--device DEV [/dev/dm-0]
|
||||
--backup-sb BLOCK [32768]
|
||||
--db PATH [inodes.db]
|
||||
--dir INUM Diagnose every file entry in this directory inode
|
||||
"""
|
||||
import argparse, struct, sys
|
||||
import ext4lib
|
||||
import ext4db
|
||||
|
||||
DEFAULT_DEV = '/dev/dm-0'
|
||||
DEFAULT_BACKUP_SB = 32768
|
||||
|
||||
FLAG_INLINE_DATA = 0x10000000
|
||||
FLAG_EXTENTS = 0x00080000
|
||||
|
||||
|
||||
def diagnose_inode(f, sb, gdt_data, db, inum, zeroed_max_inum):
|
||||
print(f"\ninode {inum}")
|
||||
print(f" {'─'*50}")
|
||||
|
||||
# ── DB metadata ───────────────────────────────────────────────────────────
|
||||
row = ext4db.get_inode(db, inum) if db else None
|
||||
if row:
|
||||
print(f" DB status : {row['status']}")
|
||||
print(f" DB size : {row['size']:,} bytes")
|
||||
print(f" DB type : {row['itype']:#06x}")
|
||||
|
||||
# ── raw inode from disk ───────────────────────────────────────────────────
|
||||
try:
|
||||
idata, slot = ext4lib.read_inode(f, sb, gdt_data, inum)
|
||||
except Exception as e:
|
||||
print(f" ERROR reading inode from disk: {e}")
|
||||
return
|
||||
|
||||
inode = ext4lib.parse_inode_full(idata, slot, sb)
|
||||
if inode is None:
|
||||
print(" ERROR inode data too short")
|
||||
return
|
||||
|
||||
size = inode['size']
|
||||
flags = inode['flags']
|
||||
status = ext4lib.classify_inode(idata, slot)
|
||||
|
||||
print(f" disk size : {size:,} bytes")
|
||||
print(f" flags : {flags:#010x}", end='')
|
||||
flag_names = []
|
||||
if flags & FLAG_INLINE_DATA: flag_names.append('INLINE_DATA')
|
||||
if flags & FLAG_EXTENTS: flag_names.append('EXTENTS')
|
||||
print(f" ({', '.join(flag_names)})" if flag_names else '')
|
||||
print(f" status : {status}")
|
||||
|
||||
if size == 0:
|
||||
print(" DIAGNOSIS : inode size is 0 — file was empty or size field is corrupted")
|
||||
return
|
||||
|
||||
# ── inline data ───────────────────────────────────────────────────────────
|
||||
if flags & FLAG_INLINE_DATA:
|
||||
inline = idata[slot + 40:slot + 40 + size]
|
||||
nonzero = sum(1 for b in inline if b != 0)
|
||||
print(f" DIAGNOSIS : inline data ({size} bytes, {nonzero} non-zero bytes in inode body)")
|
||||
return
|
||||
|
||||
# ── extent tree ───────────────────────────────────────────────────────────
|
||||
base = slot + 40
|
||||
magic, entries, _, depth = struct.unpack_from('<HHHH', idata, base)
|
||||
print(f" ext magic : {magic:#06x} {'OK' if magic == 0xF30A else 'BAD — not a valid extent header'}")
|
||||
print(f" ext depth : {depth} entries: {entries}")
|
||||
|
||||
if magic != 0xF30A:
|
||||
print(" DIAGNOSIS : invalid extent tree magic — inode body is corrupt/zeroed")
|
||||
return
|
||||
|
||||
blocks = ext4lib.read_extent_tree_blocks(f, idata, slot)
|
||||
print(f" extents : {len(blocks)} logical→physical block pairs")
|
||||
|
||||
if not blocks:
|
||||
print(" DIAGNOSIS : extent tree parsed but returned no blocks")
|
||||
print(" (entries=0, or all extents have phys=0, or index nodes unreadable)")
|
||||
return
|
||||
|
||||
zeroed = [(l, p) for l, p in blocks if p * ext4lib.BLOCK // ext4lib.BLOCK <= zeroed_max_inum * 8]
|
||||
# More direct check: block in zeroed region means block < zeroed_groups * blocks_per_group
|
||||
zeroed_block_max = ext4db.get_fs_meta_int(db, 'zeroed_groups', 13) * \
|
||||
ext4db.get_fs_meta_int(db, 'blocks_per_group', 32768) if db else 0
|
||||
zeroed_blocks = [(l, p) for l, p in blocks if p < zeroed_block_max]
|
||||
good_blocks = [(l, p) for l, p in blocks if p >= zeroed_block_max]
|
||||
|
||||
print(f" blocks in zeroed region : {len(zeroed_blocks)}/{len(blocks)}")
|
||||
print(f" blocks in good region : {len(good_blocks)}/{len(blocks)}")
|
||||
|
||||
# Sample first good and first zeroed block
|
||||
if zeroed_blocks and not good_blocks:
|
||||
print(" DIAGNOSIS : ALL data blocks are in the zeroed region — file content is lost")
|
||||
print(f" first block: phys={zeroed_blocks[0][1]} (zeroed region ends at block {zeroed_block_max})")
|
||||
return
|
||||
|
||||
if zeroed_blocks and good_blocks:
|
||||
print(" DIAGNOSIS : PARTIAL — some blocks recoverable, some in zeroed region")
|
||||
print(f" {len(good_blocks)} of {len(blocks)} blocks are readable")
|
||||
|
||||
# Check readability of first block
|
||||
l0, p0 = blocks[0]
|
||||
try:
|
||||
bdata = ext4lib.read_at(f, p0 * ext4lib.BLOCK, ext4lib.BLOCK)
|
||||
nonzero = sum(1 for b in bdata if b != 0)
|
||||
print(f" first block phys={p0}: {nonzero}/4096 non-zero bytes")
|
||||
if nonzero == 0:
|
||||
print(" DIAGNOSIS : first data block reads as all zeros — block content wiped or wrong address")
|
||||
else:
|
||||
print(" DIAGNOSIS : data blocks appear readable — extraction should work")
|
||||
except OSError as e:
|
||||
print(f" first block read error: {e}")
|
||||
print(" DIAGNOSIS : I/O error reading data block")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Diagnose why an inode extracted as empty')
|
||||
parser.add_argument('inodes', nargs='*', type=int)
|
||||
parser.add_argument('--device', default=DEFAULT_DEV)
|
||||
parser.add_argument('--backup-sb', type=int, default=DEFAULT_BACKUP_SB)
|
||||
parser.add_argument('--db', default='inodes.db')
|
||||
parser.add_argument('--dir', type=int, metavar='INUM',
|
||||
help='Diagnose all file entries under this directory inode')
|
||||
args = parser.parse_args()
|
||||
|
||||
db = ext4db.open_db(args.db)
|
||||
zeroed_max_inum = (ext4db.get_fs_meta_int(db, 'zeroed_groups', 13) *
|
||||
ext4db.get_fs_meta_int(db, 'inodes_per_group', 8192))
|
||||
|
||||
inodes = list(args.inodes)
|
||||
|
||||
if args.dir:
|
||||
entries = ext4db.get_dir_entries(db, args.dir)
|
||||
for name, (child_inum, ftype) in sorted(entries.items()):
|
||||
if name in ('.', '..'):
|
||||
continue
|
||||
if ftype == ext4lib.FTYPE_REG or ftype == 0:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {name!r} (inode {child_inum})")
|
||||
inodes.append(child_inum)
|
||||
|
||||
if not inodes:
|
||||
parser.error('Provide inode numbers or use --dir <inode>')
|
||||
|
||||
with open(args.device, 'rb') as f:
|
||||
sb, gdt_data, _ = ext4lib.load_fs(f, args.backup_sb)
|
||||
for inum in inodes:
|
||||
diagnose_inode(f, sb, gdt_data, db, inum, zeroed_max_inum)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
215
ext4db.py
Executable file
215
ext4db.py
Executable file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite persistence layer for the ext4 recovery pipeline.
|
||||
|
||||
Schema:
|
||||
filesystem_meta – superblock geometry + scan parameters
|
||||
inodes – per-inode metadata (mode, timestamps, status, …)
|
||||
dir_entries – directory name → child inode mappings
|
||||
scanned_groups – which block groups have been fully scanned (for resume)
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS filesystem_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inodes (
|
||||
inum INTEGER PRIMARY KEY,
|
||||
grp INTEGER NOT NULL,
|
||||
mode INTEGER,
|
||||
itype INTEGER,
|
||||
uid INTEGER,
|
||||
gid INTEGER,
|
||||
size INTEGER,
|
||||
atime INTEGER,
|
||||
ctime INTEGER,
|
||||
mtime INTEGER,
|
||||
dtime INTEGER,
|
||||
links INTEGER,
|
||||
flags INTEGER,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dir_entries (
|
||||
parent_inum INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
child_inum INTEGER NOT NULL,
|
||||
ftype INTEGER,
|
||||
PRIMARY KEY (parent_inum, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scanned_groups (
|
||||
grp INTEGER PRIMARY KEY,
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inodes_itype ON inodes(itype);
|
||||
CREATE INDEX IF NOT EXISTS idx_inodes_status ON inodes(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_de_parent ON dir_entries(parent_inum);
|
||||
CREATE INDEX IF NOT EXISTS idx_de_child ON dir_entries(child_inum);
|
||||
"""
|
||||
|
||||
|
||||
def open_db(path):
|
||||
"""Open (or create) the recovery database. Returns a sqlite3.Connection."""
|
||||
db = sqlite3.connect(path)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.executescript(_SCHEMA)
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
# ── filesystem metadata ───────────────────────────────────────────────────────
|
||||
|
||||
def save_fs_meta(db, sb, device, backup_sb_block, zeroed_groups=0):
|
||||
meta = {
|
||||
'device': device,
|
||||
'backup_sb_block': backup_sb_block,
|
||||
'zeroed_groups': zeroed_groups,
|
||||
'inodes_count': sb['inodes_count'],
|
||||
'blocks_count': sb['blocks_count'],
|
||||
'blocks_per_group': sb['blocks_per_group'],
|
||||
'inodes_per_group': sb['inodes_per_group'],
|
||||
'inode_size': sb['inode_size'],
|
||||
'desc_size': sb['desc_size'],
|
||||
}
|
||||
db.executemany(
|
||||
"INSERT OR REPLACE INTO filesystem_meta VALUES (?, ?)",
|
||||
((k, str(v)) for k, v in meta.items()),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_fs_meta(db):
|
||||
"""Return filesystem_meta as a plain dict (all values are strings)."""
|
||||
rows = db.execute("SELECT key, value FROM filesystem_meta").fetchall()
|
||||
return {r['key']: r['value'] for r in rows}
|
||||
|
||||
|
||||
def get_fs_meta_int(db, key, default=0):
|
||||
row = db.execute("SELECT value FROM filesystem_meta WHERE key=?", (key,)).fetchone()
|
||||
return int(row['value']) if row else default
|
||||
|
||||
|
||||
# ── inode table ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _i64(v):
|
||||
"""Convert an unsigned Python int to SQLite-safe signed 64-bit integer.
|
||||
|
||||
Corrupted inodes can produce 64-bit values (e.g. size = size_lo | size_hi<<32)
|
||||
that exceed SQLite's signed INTEGER max (2^63-1) and cause OverflowError.
|
||||
"""
|
||||
v = int(v) & 0xFFFFFFFFFFFFFFFF
|
||||
return v - (1 << 64) if v >= (1 << 63) else v
|
||||
|
||||
|
||||
def save_inode(db, inum, grp, inode, status):
|
||||
db.execute(
|
||||
"""INSERT OR REPLACE INTO inodes
|
||||
(inum, grp, mode, itype, uid, gid, size, atime, ctime, mtime, dtime, links, flags, status)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
inum, grp,
|
||||
_i64(inode.get('mode', 0)),
|
||||
_i64(inode.get('type', 0)),
|
||||
_i64(inode.get('uid', 0)),
|
||||
_i64(inode.get('gid', 0)),
|
||||
_i64(inode.get('size', 0)),
|
||||
_i64(inode.get('atime', 0)),
|
||||
_i64(inode.get('ctime', 0)),
|
||||
_i64(inode.get('mtime', 0)),
|
||||
_i64(inode.get('dtime', 0)),
|
||||
_i64(inode.get('links', 0)),
|
||||
_i64(inode.get('flags', 0)),
|
||||
status,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_inode(db, inum):
|
||||
"""Return the inode row or None."""
|
||||
return db.execute("SELECT * FROM inodes WHERE inum=?", (inum,)).fetchone()
|
||||
|
||||
|
||||
def get_all_dir_inums(db, include_deleted=False):
|
||||
"""Return list of inode numbers for all directory inodes."""
|
||||
ITYPE_DIR = 0x4000
|
||||
if include_deleted:
|
||||
rows = db.execute(
|
||||
"SELECT inum FROM inodes WHERE itype=? AND status != 'unallocated'",
|
||||
(ITYPE_DIR,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT inum FROM inodes WHERE itype=? AND status='active'",
|
||||
(ITYPE_DIR,),
|
||||
).fetchall()
|
||||
return [r['inum'] for r in rows]
|
||||
|
||||
|
||||
# ── directory entries ─────────────────────────────────────────────────────────
|
||||
|
||||
def save_dir_entry(db, parent_inum, name, child_inum, ftype):
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO dir_entries (parent_inum, name, child_inum, ftype) VALUES (?,?,?,?)",
|
||||
(parent_inum, name, child_inum, ftype),
|
||||
)
|
||||
|
||||
|
||||
def get_dir_entries(db, parent_inum):
|
||||
"""Return dict of name -> (child_inum, ftype) for a directory."""
|
||||
rows = db.execute(
|
||||
"SELECT name, child_inum, ftype FROM dir_entries WHERE parent_inum=?",
|
||||
(parent_inum,),
|
||||
).fetchall()
|
||||
return {r['name']: (r['child_inum'], r['ftype']) for r in rows}
|
||||
|
||||
|
||||
def get_dotdot(db, inum):
|
||||
"""Return the parent inode number recorded in the .. entry, or None."""
|
||||
row = db.execute(
|
||||
"SELECT child_inum FROM dir_entries WHERE parent_inum=? AND name='..'",
|
||||
(inum,),
|
||||
).fetchone()
|
||||
return row['child_inum'] if row else None
|
||||
|
||||
|
||||
def get_dot(db, inum):
|
||||
"""Return the inode number recorded in the . entry, or None."""
|
||||
row = db.execute(
|
||||
"SELECT child_inum FROM dir_entries WHERE parent_inum=? AND name='.'",
|
||||
(inum,),
|
||||
).fetchone()
|
||||
return row['child_inum'] if row else None
|
||||
|
||||
|
||||
# ── scan progress ─────────────────────────────────────────────────────────────
|
||||
|
||||
def mark_group_scanned(db, grp):
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO scanned_groups VALUES (?, ?)",
|
||||
(grp, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
|
||||
|
||||
def get_scanned_groups(db):
|
||||
"""Return set of already-scanned group numbers."""
|
||||
rows = db.execute("SELECT grp FROM scanned_groups").fetchall()
|
||||
return {r['grp'] for r in rows}
|
||||
|
||||
|
||||
# ── summary stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
def print_stats(db):
|
||||
total = db.execute("SELECT COUNT(*) FROM inodes").fetchone()[0]
|
||||
active = db.execute("SELECT COUNT(*) FROM inodes WHERE status='active'").fetchone()[0]
|
||||
dirs = db.execute("SELECT COUNT(*) FROM inodes WHERE itype=0x4000").fetchone()[0]
|
||||
scanned = db.execute("SELECT COUNT(*) FROM scanned_groups").fetchone()[0]
|
||||
dentries = db.execute("SELECT COUNT(*) FROM dir_entries").fetchone()[0]
|
||||
print(f" inodes scanned : {total:>10,} (active={active:,}, dirs={dirs:,})")
|
||||
print(f" dir entries : {dentries:>10,}")
|
||||
print(f" groups scanned : {scanned:>10,}")
|
||||
343
ext4lib.py
Executable file
343
ext4lib.py
Executable file
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
EXT4 low-level filesystem primitives.
|
||||
|
||||
All functions that need a file handle expect it open in 'rb' mode.
|
||||
"""
|
||||
import os, stat, struct, sys
|
||||
|
||||
BLOCK = 4096
|
||||
|
||||
FTYPE_REG = 1
|
||||
FTYPE_DIR = 2
|
||||
FTYPE_SYM = 7
|
||||
|
||||
ITYPE_REG = 0x8000
|
||||
ITYPE_DIR = 0x4000
|
||||
ITYPE_SYM = 0xA000
|
||||
|
||||
|
||||
# ── block I/O ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_at(f, offset, size):
|
||||
f.seek(offset)
|
||||
return f.read(size)
|
||||
|
||||
|
||||
# ── superblock / GDT ──────────────────────────────────────────────────────────
|
||||
|
||||
def parse_superblock(data):
|
||||
sb = {}
|
||||
sb['inodes_count'] = struct.unpack_from('<I', data, 0)[0]
|
||||
sb['blocks_count'] = struct.unpack_from('<I', data, 4)[0]
|
||||
sb['blocks_per_group'] = struct.unpack_from('<I', data, 32)[0]
|
||||
sb['inodes_per_group'] = struct.unpack_from('<I', data, 40)[0]
|
||||
sb['inode_size'] = struct.unpack_from('<H', data, 88)[0]
|
||||
sb['magic'] = struct.unpack_from('<H', data, 56)[0]
|
||||
sb['feature_incompat'] = struct.unpack_from('<I', data, 96)[0]
|
||||
sb['desc_size'] = struct.unpack_from('<H', data, 254)[0] or 32
|
||||
return sb
|
||||
|
||||
|
||||
def parse_gdt_entry(gdt_data, offset, desc_size):
|
||||
"""Return inode table block number from a group descriptor entry."""
|
||||
lo = struct.unpack_from('<I', gdt_data, offset + 8)[0]
|
||||
if desc_size >= 64:
|
||||
hi = struct.unpack_from('<I', gdt_data, offset + 40)[0]
|
||||
return lo | (hi << 32)
|
||||
return lo
|
||||
|
||||
|
||||
def load_fs(f, backup_sb_block):
|
||||
"""Read superblock and full GDT from backup location.
|
||||
|
||||
Returns (sb, gdt_data, num_groups).
|
||||
Raises AssertionError if the magic number is wrong.
|
||||
"""
|
||||
sb_data = read_at(f, backup_sb_block * BLOCK, 1024)
|
||||
sb = parse_superblock(sb_data)
|
||||
assert sb['magic'] == 0xef53, f"Bad superblock magic: {sb['magic']:#x}"
|
||||
num_groups = (sb['blocks_count'] + sb['blocks_per_group'] - 1) // sb['blocks_per_group']
|
||||
gdt_data = read_at(f, (backup_sb_block + 1) * BLOCK, num_groups * sb['desc_size'])
|
||||
return sb, gdt_data, num_groups
|
||||
|
||||
|
||||
# ── extent tree ───────────────────────────────────────────────────────────────
|
||||
|
||||
def read_extent_tree_blocks(f, data, inode_offset):
|
||||
"""Return sorted list of (logical_block, phys_block) pairs for an inode.
|
||||
|
||||
data – the BLOCK-sized buffer that contains the inode
|
||||
inode_offset – byte offset of the inode within data
|
||||
"""
|
||||
base = inode_offset + 40
|
||||
magic, entries, _, depth = struct.unpack_from('<HHHH', data, base)
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
return _walk_extent_node(f, data, base, depth)
|
||||
|
||||
|
||||
def _walk_extent_node(f, data, base, depth):
|
||||
magic, entries, _, _ = struct.unpack_from('<HHHH', data, base)
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
|
||||
result = []
|
||||
if depth == 0:
|
||||
for i in range(entries):
|
||||
o = base + 12 + i * 12
|
||||
l_block = struct.unpack_from('<I', data, o )[0]
|
||||
ee_len = struct.unpack_from('<H', data, o + 4)[0]
|
||||
start_hi = struct.unpack_from('<H', data, o + 6)[0]
|
||||
start_lo = struct.unpack_from('<I', data, o + 8)[0]
|
||||
phys = (start_hi << 32) | start_lo
|
||||
if phys > 0:
|
||||
for b in range(ee_len & 0x7FFF):
|
||||
result.append((l_block + b, phys + b))
|
||||
else:
|
||||
for i in range(entries):
|
||||
o = base + 12 + i * 12
|
||||
leaf_lo = struct.unpack_from('<I', data, o + 4)[0]
|
||||
leaf_hi = struct.unpack_from('<H', data, o + 8)[0]
|
||||
leaf_block = (leaf_hi << 32) | leaf_lo
|
||||
try:
|
||||
child_data = read_at(f, leaf_block * BLOCK, BLOCK)
|
||||
result.extend(_walk_extent_node(f, child_data, 0, depth - 1))
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ── inode ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_inode(f, sb, gdt_data, inum):
|
||||
"""Return (block_data, offset_within_block) for the given inode number."""
|
||||
grp = (inum - 1) // sb['inodes_per_group']
|
||||
local_idx = (inum - 1) % sb['inodes_per_group']
|
||||
tbl_block = parse_gdt_entry(gdt_data, grp * sb['desc_size'], sb['desc_size'])
|
||||
byte_off = local_idx * sb['inode_size']
|
||||
blk_off = byte_off // BLOCK
|
||||
slot = byte_off % BLOCK
|
||||
data = read_at(f, (tbl_block + blk_off) * BLOCK, BLOCK)
|
||||
return data, slot
|
||||
|
||||
|
||||
def parse_inode_full(data, offset, sb):
|
||||
"""Parse all fields from a raw inode buffer.
|
||||
|
||||
Returns a dict, or None if the buffer is too short.
|
||||
"""
|
||||
if len(data) - offset < 128:
|
||||
return None
|
||||
|
||||
mode = struct.unpack_from('<H', data, offset + 0)[0]
|
||||
uid_lo = struct.unpack_from('<H', data, offset + 2)[0]
|
||||
size_lo = struct.unpack_from('<I', data, offset + 4)[0]
|
||||
atime = struct.unpack_from('<I', data, offset + 8)[0]
|
||||
ctime = struct.unpack_from('<I', data, offset + 12)[0]
|
||||
mtime = struct.unpack_from('<I', data, offset + 16)[0]
|
||||
dtime = struct.unpack_from('<I', data, offset + 20)[0]
|
||||
gid_lo = struct.unpack_from('<H', data, offset + 24)[0]
|
||||
links = struct.unpack_from('<H', data, offset + 26)[0]
|
||||
flags = struct.unpack_from('<I', data, offset + 32)[0]
|
||||
uid_hi, gid_hi = struct.unpack_from('<HH', data, offset + 120)
|
||||
|
||||
size_hi = 0
|
||||
if sb.get('inode_size', 128) >= 256 and len(data) - offset >= 164:
|
||||
size_hi = struct.unpack_from('<I', data, offset + 108)[0]
|
||||
|
||||
return {
|
||||
'mode': mode,
|
||||
'type': mode & 0xF000,
|
||||
'uid': uid_lo | (uid_hi << 16),
|
||||
'gid': gid_lo | (gid_hi << 16),
|
||||
'size': size_lo | (size_hi << 32),
|
||||
'atime': atime,
|
||||
'ctime': ctime,
|
||||
'mtime': mtime,
|
||||
'dtime': dtime,
|
||||
'links': links,
|
||||
'flags': flags,
|
||||
}
|
||||
|
||||
|
||||
def classify_inode(idata, slot):
|
||||
"""Return 'active', 'deleted', 'corrupt', or 'unallocated'."""
|
||||
links = struct.unpack_from('<H', idata, slot + 26)[0]
|
||||
dtime = struct.unpack_from('<I', idata, slot + 20)[0]
|
||||
|
||||
if dtime != 0 and links == 0:
|
||||
return 'deleted'
|
||||
if dtime != 0 and links > 0:
|
||||
return 'corrupt'
|
||||
if dtime == 0 and links == 0:
|
||||
return 'unallocated'
|
||||
return 'active'
|
||||
|
||||
|
||||
def get_inode_meta(idata, slot, sb):
|
||||
"""Return (permissions, uid, gid, atime, mtime) from a raw inode buffer."""
|
||||
mode = struct.unpack_from('<H', idata, slot + 0)[0]
|
||||
uid_lo = struct.unpack_from('<H', idata, slot + 2)[0]
|
||||
gid_lo = struct.unpack_from('<H', idata, slot + 24)[0]
|
||||
atime = struct.unpack_from('<I', idata, slot + 8)[0]
|
||||
mtime = struct.unpack_from('<I', idata, slot + 16)[0]
|
||||
|
||||
uid_hi, gid_hi = struct.unpack_from('<HH', idata, slot + 120)
|
||||
uid = uid_lo | (uid_hi << 16)
|
||||
gid = gid_lo | (gid_hi << 16)
|
||||
|
||||
if sb.get('inode_size', 128) >= 256:
|
||||
atime_extra = struct.unpack_from('<I', idata, slot + 132)[0]
|
||||
mtime_extra = struct.unpack_from('<I', idata, slot + 140)[0]
|
||||
atime |= (atime_extra & 0x3) << 32
|
||||
mtime |= (mtime_extra & 0x3) << 32
|
||||
|
||||
return stat.S_IMODE(mode), uid, gid, atime, mtime
|
||||
|
||||
|
||||
# ── directory entries ─────────────────────────────────────────────────────────
|
||||
|
||||
def read_dir_entries_raw(f, idata, inode_offset):
|
||||
"""Read directory entries given raw inode data (already in memory).
|
||||
|
||||
Returns dict of name -> (child_inum, ftype).
|
||||
"""
|
||||
entries = {}
|
||||
for _logical, phys in sorted(read_extent_tree_blocks(f, idata, inode_offset)):
|
||||
try:
|
||||
bdata = read_at(f, phys * BLOCK, BLOCK)
|
||||
offset = 0
|
||||
while offset < BLOCK - 8:
|
||||
e_ino, rec_len, name_len, ftype = struct.unpack_from('<IHBB', bdata, offset)
|
||||
if rec_len < 8 or offset + rec_len > BLOCK:
|
||||
break
|
||||
if e_ino != 0 and name_len > 0:
|
||||
name = bdata[offset + 8:offset + 8 + name_len].decode('utf-8', errors='replace')
|
||||
entries[name] = (e_ino, ftype)
|
||||
offset += rec_len
|
||||
except OSError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def read_dir_entries(f, sb, gdt_data, inum):
|
||||
"""Read directory entries for inode inum. Returns dict of name -> (child_inum, ftype)."""
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
return read_dir_entries_raw(f, idata, slot)
|
||||
|
||||
|
||||
# ── file extraction ───────────────────────────────────────────────────────────
|
||||
|
||||
def dump_file(f, sb, gdt_data, inum, dest_path):
|
||||
"""Extract a regular file by inode number to dest_path. Returns True on success."""
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
size_lo = struct.unpack_from('<I', idata, slot + 4)[0]
|
||||
size_hi = struct.unpack_from('<I', idata, slot + 108)[0]
|
||||
size = size_lo | (size_hi << 32)
|
||||
flags = struct.unpack_from('<I', idata, slot + 32)[0]
|
||||
|
||||
if flags & 0x10000000:
|
||||
inline = idata[slot + 40:slot + 40 + size]
|
||||
with open(dest_path, 'wb') as out:
|
||||
out.write(inline)
|
||||
return True
|
||||
|
||||
blocks = sorted(read_extent_tree_blocks(f, idata, slot))
|
||||
written = 0
|
||||
with open(dest_path, 'wb') as out:
|
||||
for logical, phys in blocks:
|
||||
hole = logical * BLOCK
|
||||
if hole > written:
|
||||
out.seek(hole)
|
||||
written = hole
|
||||
remaining = size - written
|
||||
if remaining <= 0:
|
||||
break
|
||||
chunk = read_at(f, phys * BLOCK, BLOCK)
|
||||
out.write(chunk[:min(BLOCK, remaining)])
|
||||
written += min(BLOCK, remaining)
|
||||
out.truncate(size)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def dump_symlink(f, sb, gdt_data, inum, dest_path):
|
||||
"""Create a symlink at dest_path from the symlink inode. Returns True on success."""
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
size = struct.unpack_from('<I', idata, slot + 4)[0]
|
||||
if size <= 60:
|
||||
target = idata[slot + 40:slot + 40 + size].decode('utf-8', errors='replace')
|
||||
else:
|
||||
extents = read_extent_tree_blocks(f, idata, slot)
|
||||
if not extents:
|
||||
return False
|
||||
bdata = read_at(f, extents[0][1] * BLOCK, BLOCK)
|
||||
target = bdata[:size].decode('utf-8', errors='replace')
|
||||
|
||||
target = target.split('\x00')[0].strip()
|
||||
|
||||
if not target or any(ord(c) < 32 for c in target):
|
||||
print(f" WARN invalid symlink target for {dest_path!r}: {target!r}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if os.path.lexists(dest_path):
|
||||
return True
|
||||
|
||||
os.symlink(target, dest_path)
|
||||
return True
|
||||
except (OSError, IndexError) as e:
|
||||
print(f" WARN symlink {dest_path}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def dump_tree(f, sb, gdt_data, inum, dest_dir, db=None, depth=0, visited=None):
|
||||
"""Recursively extract a directory tree.
|
||||
|
||||
If db is provided (an ext4db connection), directory entries are read from
|
||||
the database instead of from disk — much faster for subsequent runs.
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if inum in visited:
|
||||
return
|
||||
visited.add(inum)
|
||||
|
||||
if db is not None:
|
||||
import ext4db
|
||||
entries = ext4db.get_dir_entries(db, inum)
|
||||
else:
|
||||
try:
|
||||
entries = read_dir_entries(f, sb, gdt_data, inum)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
for name, (child_inum, ftype) in entries.items():
|
||||
if name in ('.', '..'):
|
||||
continue
|
||||
safe_name = name.replace('/', '_').replace('\x00', '')
|
||||
dest = os.path.join(dest_dir, safe_name)
|
||||
|
||||
try:
|
||||
if ftype == 0:
|
||||
idata, slot = read_inode(f, sb, gdt_data, child_inum)
|
||||
itype = struct.unpack_from('<H', idata, slot)[0] & 0xF000
|
||||
if itype == ITYPE_DIR: ftype = FTYPE_DIR
|
||||
elif itype == ITYPE_REG: ftype = FTYPE_REG
|
||||
elif itype == ITYPE_SYM: ftype = FTYPE_SYM
|
||||
|
||||
if ftype == FTYPE_DIR:
|
||||
dump_tree(f, sb, gdt_data, child_inum, dest, db=db,
|
||||
depth=depth + 1, visited=visited)
|
||||
elif ftype == FTYPE_REG:
|
||||
dump_file(f, sb, gdt_data, child_inum, dest)
|
||||
elif ftype == FTYPE_SYM:
|
||||
dump_symlink(f, sb, gdt_data, child_inum, dest)
|
||||
|
||||
except Exception as e:
|
||||
print(f" WARN {dest}: {e}", file=sys.stderr)
|
||||
210
extract_tree.py
Executable file
210
extract_tree.py
Executable file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stage 3 – Extract directory trees from the device to a destination path.
|
||||
|
||||
Reads the inode→file mapping from the SQLite database (Stage 1 output) so
|
||||
directory traversal never needs to re-read the inode tables from disk.
|
||||
Only actual file data blocks are read from the device.
|
||||
|
||||
After extraction, optionally restores permissions/ownership/timestamps from
|
||||
the inode metadata stored in the database (or read live from disk).
|
||||
|
||||
Usage:
|
||||
python3 extract_tree.py [options] <inode> [<inode> ...]
|
||||
|
||||
# Extract all roots from Stage 2 output file
|
||||
python3 extract_tree.py --from-file orphan_roots.txt [options]
|
||||
|
||||
Options:
|
||||
--device DEV Block device [/dev/dm-0]
|
||||
--backup-sb BLOCK Backup superblock block number [32768]
|
||||
--db PATH SQLite database [inodes.db]
|
||||
--dest DIR Destination base directory [/mnt/recovered]
|
||||
--restore-meta Apply uid/gid/mode/timestamps after extraction
|
||||
--skip-existing Skip an inode if its destination already exists
|
||||
--active-only Skip inodes whose DB status is not 'active' [default]
|
||||
--include-deleted Also extract deleted/corrupt inodes
|
||||
--from-file FILE Read inode list from file (one inode per line,
|
||||
extra columns ignored)
|
||||
"""
|
||||
import argparse, os, stat, sys
|
||||
import ext4lib
|
||||
import ext4db
|
||||
|
||||
DEFAULT_DEV = '/dev/dm-0'
|
||||
DEFAULT_BACKUP_SB = 32768
|
||||
DEFAULT_DEST = '/mnt/recovered'
|
||||
|
||||
|
||||
# ── metadata restore ──────────────────────────────────────────────────────────
|
||||
|
||||
import ctypes, ctypes.util
|
||||
|
||||
_libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True)
|
||||
|
||||
class _Timeval(ctypes.Structure):
|
||||
_fields_ = [('tv_sec', ctypes.c_long), ('tv_usec', ctypes.c_long)]
|
||||
|
||||
def _lutimes(path, atime, mtime):
|
||||
times = (_Timeval * 2)((_Timeval(atime, 0)), (_Timeval(mtime, 0)))
|
||||
_libc.lutimes(path.encode(), ctypes.byref(times))
|
||||
|
||||
|
||||
def restore_meta_from_disk(f, sb, gdt_data, inum, dest_path):
|
||||
try:
|
||||
idata, slot = ext4lib.read_inode(f, sb, gdt_data, inum)
|
||||
perms, uid, gid, atime, mtime = ext4lib.get_inode_meta(idata, slot, sb)
|
||||
_apply_meta(dest_path, perms, uid, gid, atime, mtime)
|
||||
except Exception as e:
|
||||
print(f" WARN meta {dest_path}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def restore_meta_from_db(db, inum, dest_path):
|
||||
row = ext4db.get_inode(db, inum)
|
||||
if row is None:
|
||||
return
|
||||
try:
|
||||
perms = stat.S_IMODE(row['mode'])
|
||||
_apply_meta(dest_path, perms, row['uid'], row['gid'], row['atime'], row['mtime'])
|
||||
except Exception as e:
|
||||
print(f" WARN meta {dest_path}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def _apply_meta(dest_path, perms, uid, gid, atime, mtime):
|
||||
is_link = os.path.islink(dest_path)
|
||||
try:
|
||||
os.lchown(dest_path, uid, gid)
|
||||
except OSError:
|
||||
pass
|
||||
if not is_link:
|
||||
try:
|
||||
os.chmod(dest_path, perms)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_lutimes(dest_path, atime, mtime)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── tree walker ───────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_tree(f, sb, gdt_data, db, inum, dest_dir,
|
||||
restore_meta=False, include_deleted=False,
|
||||
visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if inum in visited:
|
||||
return
|
||||
visited.add(inum)
|
||||
|
||||
entries = ext4db.get_dir_entries(db, inum)
|
||||
if not entries:
|
||||
# Fallback: read from disk (dir entries not in DB)
|
||||
try:
|
||||
entries = ext4lib.read_dir_entries(f, sb, gdt_data, inum)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
for name, (child_inum, ftype) in entries.items():
|
||||
if name in ('.', '..'):
|
||||
continue
|
||||
|
||||
# Status check
|
||||
if not include_deleted:
|
||||
row = ext4db.get_inode(db, child_inum)
|
||||
if row and row['status'] not in ('active', None):
|
||||
continue
|
||||
|
||||
safe_name = name.replace('/', '_').replace('\x00', '')
|
||||
dest = os.path.join(dest_dir, safe_name)
|
||||
|
||||
try:
|
||||
# Derive ftype from DB itype if not set in dir entry
|
||||
if ftype == 0:
|
||||
row = ext4db.get_inode(db, child_inum)
|
||||
if row:
|
||||
itype = row['itype']
|
||||
if itype == ext4lib.ITYPE_DIR: ftype = ext4lib.FTYPE_DIR
|
||||
elif itype == ext4lib.ITYPE_REG: ftype = ext4lib.FTYPE_REG
|
||||
elif itype == ext4lib.ITYPE_SYM: ftype = ext4lib.FTYPE_SYM
|
||||
|
||||
if ftype == ext4lib.FTYPE_DIR:
|
||||
extract_tree(f, sb, gdt_data, db, child_inum, dest,
|
||||
restore_meta=restore_meta,
|
||||
include_deleted=include_deleted,
|
||||
visited=visited)
|
||||
elif ftype == ext4lib.FTYPE_REG:
|
||||
ext4lib.dump_file(f, sb, gdt_data, child_inum, dest)
|
||||
elif ftype == ext4lib.FTYPE_SYM:
|
||||
ext4lib.dump_symlink(f, sb, gdt_data, child_inum, dest)
|
||||
|
||||
if restore_meta and os.path.lexists(dest):
|
||||
restore_meta_from_db(db, child_inum, dest)
|
||||
|
||||
except Exception as e:
|
||||
print(f" WARN {dest}: {e}", file=sys.stderr)
|
||||
|
||||
# Restore directory metadata last (writing children updates parent mtime)
|
||||
if restore_meta:
|
||||
restore_meta_from_db(db, inum, dest_dir)
|
||||
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Extract orphaned ext4 trees (Stage 3)')
|
||||
parser.add_argument('inodes', nargs='*', type=int)
|
||||
parser.add_argument('--device', default=DEFAULT_DEV)
|
||||
parser.add_argument('--backup-sb', type=int, default=DEFAULT_BACKUP_SB)
|
||||
parser.add_argument('--db', default='inodes.db')
|
||||
parser.add_argument('--dest', default=DEFAULT_DEST)
|
||||
parser.add_argument('--restore-meta', action='store_true')
|
||||
parser.add_argument('--skip-existing', action='store_true')
|
||||
parser.add_argument('--include-deleted', action='store_true')
|
||||
parser.add_argument('--from-file', metavar='FILE',
|
||||
help='Read inode list from file (first column = inode number)')
|
||||
args = parser.parse_args()
|
||||
|
||||
inodes = list(args.inodes)
|
||||
|
||||
if args.from_file:
|
||||
with open(args.from_file) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
inodes.append(int(line.split()[0]))
|
||||
|
||||
if not inodes:
|
||||
parser.error('Provide at least one inode or use --from-file')
|
||||
|
||||
db = ext4db.open_db(args.db)
|
||||
|
||||
with open(args.device, 'rb') as f:
|
||||
sb, gdt_data, _ = ext4lib.load_fs(f, args.backup_sb)
|
||||
|
||||
for inum in inodes:
|
||||
dest = os.path.join(args.dest, str(inum))
|
||||
|
||||
if args.skip_existing and os.path.isdir(dest) and os.listdir(dest):
|
||||
print(f"Skipping {inum} → {dest} (already exists)")
|
||||
continue
|
||||
|
||||
# Status filter
|
||||
if not args.include_deleted:
|
||||
row = ext4db.get_inode(db, inum)
|
||||
if row and row['status'] not in ('active', None):
|
||||
print(f"Skipping {inum} (status={row['status']})")
|
||||
continue
|
||||
|
||||
print(f"Extracting inode {inum} → {dest}")
|
||||
extract_tree(f, sb, gdt_data, db, inum, dest,
|
||||
restore_meta=args.restore_meta,
|
||||
include_deleted=args.include_deleted)
|
||||
print(f" done inode {inum}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
118
find_orphans.py
Executable file
118
find_orphans.py
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stage 2 – Identify orphaned directory trees from the inode database.
|
||||
|
||||
A directory is orphaned when its '..' parent inode either:
|
||||
• points into the zeroed/damaged region of the disk
|
||||
• does not exist in the inode table at all
|
||||
• is self-referential (inum == parent inum)
|
||||
|
||||
A "true root" is an orphan whose own parent is NOT itself an orphan, i.e. it
|
||||
is the topmost detached node of a subtree.
|
||||
|
||||
No device access required – reads only from the SQLite database produced by
|
||||
scan_inodes.py.
|
||||
|
||||
Usage:
|
||||
python3 find_orphans.py [options]
|
||||
|
||||
Options:
|
||||
--db PATH SQLite database from scan_inodes.py [inodes.db]
|
||||
--output FILE Write inode list here [orphan_roots.txt]
|
||||
--include-deleted Also include directories with dtime set (deleted)
|
||||
--list Print results to stdout in addition to the file
|
||||
"""
|
||||
import argparse, datetime, sys
|
||||
import ext4db
|
||||
|
||||
|
||||
def find_orphan_roots(db, include_deleted=False):
|
||||
"""Return list of (inum, parent_inum, status, reason) for true orphan roots."""
|
||||
|
||||
zeroed_groups = ext4db.get_fs_meta_int(db, 'zeroed_groups', 13)
|
||||
inodes_per_group = ext4db.get_fs_meta_int(db, 'inodes_per_group', 8192)
|
||||
# Highest inode number in the zeroed region
|
||||
zeroed_max_inum = zeroed_groups * inodes_per_group
|
||||
|
||||
dir_inums = set(ext4db.get_all_dir_inums(db, include_deleted=include_deleted))
|
||||
|
||||
orphan_roots = []
|
||||
|
||||
for inum in dir_inums:
|
||||
dot = ext4db.get_dot(db, inum)
|
||||
dotdot = ext4db.get_dotdot(db, inum)
|
||||
|
||||
# Require that '.' points back to this inode — strongest confirmation
|
||||
# that we read a real directory block (not garbage or a false-positive
|
||||
# inode slot where mode & 0xF000 == 0x4000 by coincidence).
|
||||
if dot != inum:
|
||||
continue
|
||||
|
||||
if dotdot is None:
|
||||
orphan_roots.append((inum, 0, 'no-dotdot'))
|
||||
continue
|
||||
|
||||
if dotdot == inum:
|
||||
orphan_roots.append((inum, dotdot, 'self-referential'))
|
||||
elif dotdot <= zeroed_max_inum:
|
||||
orphan_roots.append((inum, dotdot, 'parent-in-zeroed-region'))
|
||||
elif dotdot not in dir_inums:
|
||||
orphan_roots.append((inum, dotdot, 'parent-missing'))
|
||||
|
||||
# Keep only true roots: orphans whose declared parent is not itself an orphan
|
||||
orphan_set = {inum for inum, _, _ in orphan_roots}
|
||||
true_roots = [
|
||||
(inum, parent, reason)
|
||||
for inum, parent, reason in orphan_roots
|
||||
if parent not in orphan_set
|
||||
]
|
||||
|
||||
# Attach inode status
|
||||
result = []
|
||||
for inum, parent, reason in sorted(true_roots):
|
||||
row = ext4db.get_inode(db, inum)
|
||||
status = row['status'] if row else 'unknown'
|
||||
result.append((inum, parent, status, reason))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_dtime(db, inum):
|
||||
row = ext4db.get_inode(db, inum)
|
||||
if not row or not row['dtime']:
|
||||
return 'never'
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(row['dtime']).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except (OSError, OverflowError):
|
||||
return str(row['dtime'])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Find orphaned directory roots (Stage 2)')
|
||||
parser.add_argument('--db', default='inodes.db')
|
||||
parser.add_argument('--output', default='orphan_roots.txt')
|
||||
parser.add_argument('--include-deleted', action='store_true')
|
||||
parser.add_argument('--list', action='store_true',
|
||||
help='Print table to stdout')
|
||||
args = parser.parse_args()
|
||||
|
||||
db = ext4db.open_db(args.db)
|
||||
roots = find_orphan_roots(db, include_deleted=args.include_deleted)
|
||||
|
||||
if args.list or args.output == '-':
|
||||
print(f"{'inode':>12} {'parent':>12} {'status':>12} {'deleted':>19} reason")
|
||||
print('-' * 80)
|
||||
for inum, parent, status, reason in roots:
|
||||
dt = format_dtime(db, inum)
|
||||
print(f"{inum:>12} {parent:>12} {status:>12} {dt:>19} {reason}")
|
||||
print(f"\nTotal orphan roots: {len(roots)}")
|
||||
|
||||
if args.output and args.output != '-':
|
||||
with open(args.output, 'w') as fh:
|
||||
for inum, parent, status, reason in roots:
|
||||
fh.write(f"{inum} {parent} {status} {reason}\n")
|
||||
print(f"Wrote {len(roots)} orphan roots to {args.output}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
80
misc_tools/check_translation.py
Normal file
80
misc_tools/check_translation.py
Normal file
@@ -0,0 +1,80 @@
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# What if block 5251104 is an ABSOLUTE block number on md0
|
||||
# (not relative to LV start)?
|
||||
# Then: md0_byte = 5251104 * 4096 = 21,508,521,984
|
||||
# And we DON'T add LV_START
|
||||
|
||||
abs_byte = 5251104 * BSIZE
|
||||
print(f'Block 5251104 as absolute md0 byte: {abs_byte}')
|
||||
print(f'md0 sector: {abs_byte//512}')
|
||||
|
||||
# Read it directly from md0 (no translation)
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(abs_byte)
|
||||
data = f.read(BSIZE)
|
||||
|
||||
import struct
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
print(f'Nonzero bytes: {nonzero}/4096')
|
||||
print(f'First 32: {data[:32].hex()}')
|
||||
|
||||
# Try as directory
|
||||
off = 0
|
||||
entries = []
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', data, off+4)[0]
|
||||
name_len= data[off+6]
|
||||
ftype = data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
entries.append((ino,name,ftype))
|
||||
off += rec_len
|
||||
|
||||
if entries:
|
||||
print('Directory entries:')
|
||||
for ino,name,ftype in entries:
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
else:
|
||||
print('No valid directory entries')
|
||||
|
||||
# Also try: what if the block number is relative to LV start
|
||||
# but WITHOUT the 4/5 chunk translation?
|
||||
# i.e. just: md0_byte = LV_START + block * BSIZE
|
||||
direct_byte = LV_START + 5251104 * BSIZE
|
||||
print()
|
||||
print(f'Block 5251104 relative to LV, no translation: {direct_byte}')
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(direct_byte)
|
||||
data2 = f.read(BSIZE)
|
||||
nonzero2 = sum(1 for b in data2 if b != 0)
|
||||
print(f'Nonzero bytes: {nonzero2}/4096')
|
||||
print(f'First 32: {data2[:32].hex()}')
|
||||
|
||||
off = 0
|
||||
entries2 = []
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', data2, off)[0]
|
||||
rec_len = struct.unpack_from('<H', data2, off+4)[0]
|
||||
name_len= data2[off+6]
|
||||
ftype = data2[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = data2[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
entries2.append((ino,name,ftype))
|
||||
off += rec_len
|
||||
|
||||
if entries2:
|
||||
print('Directory entries (no translation):')
|
||||
for ino,name,ftype in entries2:
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
else:
|
||||
print('No valid directory entries')
|
||||
33
misc_tools/check_volumes.py
Normal file
33
misc_tools/check_volumes.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import subprocess, struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# Check istat for volumes dir (inode 1585918) - we know fls works on this
|
||||
r = subprocess.run(['istat', '/dev/nbd0', '1585918'],
|
||||
capture_output=True, text=True)
|
||||
print('istat for volumes (inode 1585918):')
|
||||
print(r.stdout)
|
||||
|
||||
# Check istat for pterodactyl dir (inode 1574102)
|
||||
r2 = subprocess.run(['istat', '/dev/nbd0', '1574102'],
|
||||
capture_output=True, text=True)
|
||||
print('istat for pterodactyl (inode 1574102):')
|
||||
print(r2.stdout)
|
||||
|
||||
# Check which chunk each data block falls in
|
||||
def check_block(block_num):
|
||||
virt = block_num * BSIZE
|
||||
group = virt // (5*CHUNK)
|
||||
in_group = virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
return chunk_idx
|
||||
|
||||
# Parse direct blocks from istat output
|
||||
for line in r.stdout.splitlines():
|
||||
if 'Direct Blocks' in line or (line.strip() and line.strip()[0].isdigit()):
|
||||
blocks = [int(x) for x in line.split() if x.isdigit()]
|
||||
for b in blocks:
|
||||
ci = check_block(b)
|
||||
print(f' block {b}: chunk_idx={ci} {"METADATA" if ci==4 else "DATA"}')
|
||||
100
misc_tools/checkfifth.py
Normal file
100
misc_tools/checkfifth.py
Normal file
@@ -0,0 +1,100 @@
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# We know block 9262 is the root directory data block
|
||||
# and it reads correctly. Let's find its chunk position.
|
||||
known_virt = 9262 * BSIZE
|
||||
group = known_virt // (5*CHUNK)
|
||||
in_group = known_virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
|
||||
print(f'Root dir block 9262: chunk_group={group} chunk_idx={chunk_idx}')
|
||||
|
||||
# Now check the 5th chunk in the SAME group
|
||||
meta_virt = group * 5 * CHUNK + 4 * CHUNK
|
||||
print(f'5th chunk in same group at phys byte: {LV_START + meta_virt}')
|
||||
|
||||
# Read 512 bytes from the 5th chunk
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(LV_START + meta_virt)
|
||||
data = f.read(512)
|
||||
print(f'First 64 bytes: {data[:64].hex()}')
|
||||
print()
|
||||
|
||||
# Compare with what we read from the PERC metadata chunk earlier
|
||||
# istat showed volumes dir data at block 5251104 (chunk_idx=4)
|
||||
# Let's check if that block's data might actually be at a different
|
||||
# physical location - maybe the PERC remapped it
|
||||
|
||||
# What if for chunk_idx=4, the data is stored in a DIFFERENT disk's
|
||||
# corresponding chunk? i.e. the PERC uses the 5th physical chunk
|
||||
# on a different disk?
|
||||
|
||||
# Our disk order: sda(0) sde(1) sdd(2) sdc(3)
|
||||
# In RAID0: chunk N goes to disk N%4
|
||||
# For a group of 5 virtual chunks:
|
||||
# virtual chunk 0 -> disk 0
|
||||
# virtual chunk 1 -> disk 1
|
||||
# virtual chunk 2 -> disk 2
|
||||
# virtual chunk 3 -> disk 3
|
||||
# virtual chunk 4 -> ??? (metadata chunk - no disk?)
|
||||
|
||||
# BUT: if the physical layout has 5 chunks per group across 4 disks
|
||||
# maybe it's: disk0 gets chunks 0,4 / disk1 gets chunk1 / etc?
|
||||
# Or maybe all 5 chunks exist on disk but chunk 4 is PERC metadata
|
||||
# that happens to occupy the same space as filesystem data?
|
||||
|
||||
# The PERC sector-level metadata we found earlier:
|
||||
# Each 64KB metadata chunk stores 512 bytes per sector of the adjacent data
|
||||
# That's 128 sectors * 512 bytes = 64KB of per-sector checksums/metadata
|
||||
# So the 5th chunk IS pure PERC internal data, not filesystem data
|
||||
|
||||
# This means: filesystem block 5251104 maps to virtual chunk_idx=4
|
||||
# BUT the actual filesystem data for that block must be stored differently
|
||||
# OR the block number in the inode is a VIRTUAL block number
|
||||
# that the PERC translates differently than we think
|
||||
|
||||
# Let's check: what if block numbers in inodes are PERC virtual block numbers?
|
||||
# PERC virtual block 5251104:
|
||||
# In PERC virtual space (5 chunks per group):
|
||||
# group = 5251104*4096 // (5*CHUNK) = ?
|
||||
perc_virt_byte = 5251104 * BSIZE
|
||||
perc_group = perc_virt_byte // (5*CHUNK)
|
||||
perc_in_group = perc_virt_byte % (5*CHUNK)
|
||||
perc_chunk_idx = perc_in_group // CHUNK
|
||||
perc_intra = perc_in_group % CHUNK
|
||||
|
||||
print(f'If block 5251104 is PERC virtual:')
|
||||
print(f' PERC group={perc_group} chunk_idx={perc_chunk_idx} intra={perc_intra}')
|
||||
|
||||
# And the physical location would be:
|
||||
# physical = LV_START + perc_group*4*CHUNK + perc_chunk_idx*CHUNK + perc_intra
|
||||
if perc_chunk_idx != 4:
|
||||
phys = LV_START + perc_group*4*CHUNK + perc_chunk_idx*CHUNK + perc_intra
|
||||
print(f' Physical byte: {phys}')
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(phys)
|
||||
data = f.read(BSIZE)
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
print(f' Nonzero: {nonzero}/4096')
|
||||
print(f' First 32: {data[:32].hex()}')
|
||||
|
||||
# Try to parse as directory
|
||||
import struct
|
||||
off = 0
|
||||
print(' Directory entries:')
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', data, off+4)[0]
|
||||
name_len= data[off+6]
|
||||
ftype = data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
off += rec_len
|
||||
else:
|
||||
print(f' Still in metadata chunk!')
|
||||
107
misc_tools/digging.py
Normal file
107
misc_tools/digging.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
|
||||
def read_virt(virt_byte, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_byte
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK)
|
||||
in_group = pos % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
seg_len = min(CHUNK-intra, remaining)
|
||||
dst_off = pos - virt_byte
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
# Cache GDT
|
||||
print('Loading full GDT...')
|
||||
NUM_GROUPS = 35728
|
||||
gdt_data = read_virt(BSIZE, NUM_GROUPS*64)
|
||||
print(f'GDT loaded: {len(gdt_data)} bytes')
|
||||
|
||||
def get_inode_table_block(group):
|
||||
entry = gdt_data[group*64:(group+1)*64]
|
||||
it_lo = struct.unpack_from('<I', entry, 8)[0]
|
||||
it_hi = struct.unpack_from('<I', entry, 40)[0]
|
||||
return (it_hi<<32)|it_lo
|
||||
|
||||
def read_inode(inode_num):
|
||||
group = (inode_num-1)//IPG
|
||||
idx = (inode_num-1)%IPG
|
||||
it_block = get_inode_table_block(group)
|
||||
return read_virt(it_block*BSIZE + idx*256, 256)
|
||||
|
||||
def get_extents(inode_data):
|
||||
blocks = []
|
||||
if struct.unpack_from('<H', inode_data, 40)[0] != 0xf30a:
|
||||
return blocks
|
||||
entries = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
if depth == 0:
|
||||
for i in range(min(entries,4)):
|
||||
off = 52+i*12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
start = (ee_hi<<32)|ee_lo
|
||||
if ee_len > 1024: continue
|
||||
for b in range(min(ee_len,8)):
|
||||
blocks.append(start+b)
|
||||
return blocks
|
||||
|
||||
def list_dir(inode_num):
|
||||
inode_data = read_inode(inode_num)
|
||||
mode = struct.unpack_from('<H', inode_data, 0)[0]
|
||||
if (mode&0xf000) != 0x4000:
|
||||
return []
|
||||
entries = []
|
||||
for blk in get_extents(inode_data):
|
||||
blk_data = read_virt(blk*BSIZE, BSIZE)
|
||||
off = 0
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', blk_data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', blk_data, off+4)[0]
|
||||
name_len= blk_data[off+6]
|
||||
ftype = blk_data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = blk_data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
if name not in ('.','..'):
|
||||
entries.append((ino, name, ftype))
|
||||
off += rec_len
|
||||
return entries
|
||||
|
||||
# Walk tree from /var
|
||||
print()
|
||||
print('=== /var (inode 1310721) ===')
|
||||
for ino, name, ftype in list_dir(1310721):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname:4s} inode={ino:10d} {name!r}')
|
||||
|
||||
print()
|
||||
for ino, name, ftype in list_dir(1310721):
|
||||
if name == 'lib':
|
||||
print(f'=== /var/lib (inode {ino}) ===')
|
||||
for i2,n2,f2 in list_dir(ino):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(f2,'?')
|
||||
print(f' {tname:4s} inode={i2:10d} {n2!r}')
|
||||
|
||||
for i2,n2,f2 in list_dir(ino):
|
||||
if n2 == 'pterodactyl':
|
||||
print(f'\n=== /var/lib/pterodactyl (inode {i2}) ===')
|
||||
for i3,n3,f3 in list_dir(i2):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(f3,'?')
|
||||
print(f' {tname:4s} inode={i3:10d} {n3!r}')
|
||||
157
misc_tools/extant_debug.py
Normal file
157
misc_tools/extant_debug.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
|
||||
def read_virt(virt_byte, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_byte
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK)
|
||||
in_group = pos % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
seg_len = min(CHUNK-intra, remaining)
|
||||
dst_off = pos - virt_byte
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
NUM_GROUPS = 35728
|
||||
gdt_data = read_virt(BSIZE, NUM_GROUPS*64)
|
||||
|
||||
def get_inode_table_block(group):
|
||||
entry = gdt_data[group*64:(group+1)*64]
|
||||
it_lo = struct.unpack_from('<I', entry, 8)[0]
|
||||
it_hi = struct.unpack_from('<I', entry, 40)[0]
|
||||
return (it_hi<<32)|it_lo
|
||||
|
||||
# Read /var inode raw and dump everything
|
||||
var_inode = 1310721
|
||||
group = (var_inode-1)//IPG
|
||||
idx = (var_inode-1)%IPG
|
||||
it_block = get_inode_table_block(group)
|
||||
it_virt = it_block*BSIZE + idx*256
|
||||
|
||||
print(f'/var inode {var_inode}:')
|
||||
print(f' group={group} idx={idx}')
|
||||
print(f' it_block={it_block}')
|
||||
print(f' inode virt byte={it_virt}')
|
||||
|
||||
# Check if this virtual byte is in a metadata chunk
|
||||
in_group = it_virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
print(f' chunk_idx={chunk_idx} (4=metadata, returns zeros)')
|
||||
|
||||
inode_data = read_virt(it_virt, 256)
|
||||
print(f' raw inode: {inode_data[:64].hex()}')
|
||||
|
||||
mode = struct.unpack_from('<H', inode_data, 0)[0]
|
||||
size = struct.unpack_from('<I', inode_data, 4)[0]
|
||||
links = struct.unpack_from('<H', inode_data, 26)[0]
|
||||
mtime = struct.unpack_from('<I', inode_data, 16)[0]
|
||||
ext_magic = struct.unpack_from('<H', inode_data, 40)[0]
|
||||
eh_entries= struct.unpack_from('<H', inode_data, 42)[0]
|
||||
eh_depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
|
||||
print(f' mode=0x{mode:04x} size={size} links={links}')
|
||||
print(f' mtime={mtime} ext_magic=0x{ext_magic:04x}')
|
||||
print(f' eh_entries={eh_entries} eh_depth={eh_depth}')
|
||||
print()
|
||||
|
||||
# Dump extent tree
|
||||
if ext_magic == 0xf30a:
|
||||
print('Extent tree:')
|
||||
if eh_depth == 0:
|
||||
for i in range(min(eh_entries,4)):
|
||||
off = 52+i*12
|
||||
ee_block = struct.unpack_from('<I', inode_data, off)[0]
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
ee_start = (ee_hi<<32)|ee_lo
|
||||
print(f' Extent {i}: logical={ee_block} len={ee_len} '
|
||||
f'phys_start={ee_start}')
|
||||
|
||||
# Check if this block is in a metadata chunk
|
||||
blk_virt = ee_start*BSIZE
|
||||
in_group = blk_virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
print(f' virt_byte={blk_virt} chunk_idx={chunk_idx}')
|
||||
|
||||
# Read the block
|
||||
blk_data = read_virt(blk_virt, BSIZE)
|
||||
nonzero = sum(1 for b in blk_data if b != 0)
|
||||
print(f' nonzero bytes: {nonzero}/4096')
|
||||
print(f' first 32 bytes: {blk_data[:32].hex()}')
|
||||
|
||||
# Try to parse as directory
|
||||
off2 = 0
|
||||
count = 0
|
||||
while off2 < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', blk_data, off2)[0]
|
||||
rec_len = struct.unpack_from('<H', blk_data, off2+4)[0]
|
||||
name_len= blk_data[off2+6]
|
||||
ftype = blk_data[off2+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = blk_data[off2+8:off2+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' entry: {tname} inode={ino} {name!r}')
|
||||
count += 1
|
||||
off2 += rec_len
|
||||
if count == 0:
|
||||
print(f' No valid directory entries found')
|
||||
else:
|
||||
print(f' depth={eh_depth} - need to follow interior nodes')
|
||||
# Read interior node
|
||||
for i in range(min(eh_entries,4)):
|
||||
off = 52+i*12
|
||||
ei_block = struct.unpack_from('<I', inode_data, off)[0]
|
||||
ei_hi = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ei_lo = struct.unpack_from('<I', inode_data, off+8)[0] # wrong offsets for idx
|
||||
# Extent index: ee_block(4) + ei_leaf_lo(4) + ei_leaf_hi(2) + unused(2)
|
||||
ei_leaf_lo = struct.unpack_from('<I', inode_data, off+4)[0]
|
||||
ei_leaf_hi = struct.unpack_from('<H', inode_data, off+8)[0]
|
||||
ei_leaf = (ei_leaf_hi<<32)|ei_leaf_lo
|
||||
print(f' Index {i}: logical={ei_block} leaf={ei_leaf}')
|
||||
|
||||
# Read the leaf block
|
||||
leaf_virt = ei_leaf*BSIZE
|
||||
leaf_data = read_virt(leaf_virt, BSIZE)
|
||||
leaf_magic = struct.unpack_from('<H', leaf_data, 0)[0]
|
||||
leaf_entries = struct.unpack_from('<H', leaf_data, 2)[0]
|
||||
leaf_depth = struct.unpack_from('<H', leaf_data, 6)[0]
|
||||
print(f' leaf magic=0x{leaf_magic:04x} entries={leaf_entries} depth={leaf_depth}')
|
||||
|
||||
if leaf_magic == 0xf30a and leaf_depth == 0:
|
||||
for j in range(min(leaf_entries,4)):
|
||||
off2 = 12+j*12
|
||||
ee_len = struct.unpack_from('<H', leaf_data, off2+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', leaf_data, off2+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', leaf_data, off2+8)[0]
|
||||
ee_start = (ee_hi<<32)|ee_lo
|
||||
print(f' Extent {j}: len={ee_len} start={ee_start}')
|
||||
|
||||
blk_data = read_virt(ee_start*BSIZE, BSIZE)
|
||||
off3 = 0
|
||||
while off3 < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', blk_data, off3)[0]
|
||||
rec_len = struct.unpack_from('<H', blk_data, off3+4)[0]
|
||||
name_len= blk_data[off3+6]
|
||||
ftype = blk_data[off3+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = blk_data[off3+8:off3+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' entry: {tname} inode={ino} {name!r}')
|
||||
off3 += rec_len
|
||||
37
misc_tools/find_chunk_location.py
Normal file
37
misc_tools/find_chunk_location.py
Normal file
@@ -0,0 +1,37 @@
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
found_byte = 47323856896
|
||||
block_num = (found_byte - LV_START) // BSIZE
|
||||
|
||||
print(f'Found at md0 byte: {found_byte}')
|
||||
print(f'LV_START: {LV_START}')
|
||||
print(f'Offset from LV: {found_byte - LV_START}')
|
||||
print(f'Block number: {block_num}')
|
||||
|
||||
virt = block_num * BSIZE
|
||||
group = virt // (5*CHUNK)
|
||||
in_group = virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
|
||||
print(f'chunk_group={group} chunk_idx={chunk_idx} intra={intra}')
|
||||
print()
|
||||
|
||||
# The block was found at chunk_idx=4 but IS readable
|
||||
# This means our assumption that chunk_idx=4 = unreadable is WRONG
|
||||
# The data IS there, we just need to read it
|
||||
|
||||
# Our current translation SKIPS chunk_idx=4
|
||||
# But the data exists at: LV_START + group*5*CHUNK + 4*CHUNK + intra
|
||||
# We've been computing: LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
# For chunk_idx < 4 these are equivalent
|
||||
# For chunk_idx = 4 we skip it entirely
|
||||
|
||||
# So the fix is: for chunk_idx=4, read from the 5th physical chunk
|
||||
# phys = LV_START + group*5*CHUNK + 4*CHUNK + intra
|
||||
phys_5chunk = LV_START + group*5*CHUNK + 4*CHUNK + intra
|
||||
print(f'Physical via 5-chunk formula: {phys_5chunk}')
|
||||
print(f'Found at: {found_byte}')
|
||||
print(f'Match: {phys_5chunk == found_byte}')
|
||||
48
misc_tools/inode2_check.py
Normal file
48
misc_tools/inode2_check.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
INODE_SZ = 256
|
||||
|
||||
def read_inode(inode_num):
|
||||
group = (inode_num-1)//IPG
|
||||
idx = (inode_num-1)%IPG
|
||||
it_block = 1070 + group*512
|
||||
it_virt = it_block*BSIZE + idx*INODE_SZ
|
||||
|
||||
grp = it_virt//(5*CHUNK)
|
||||
in_group = it_virt%(5*CHUNK)
|
||||
chunk_idx= in_group//CHUNK
|
||||
intra = in_group%CHUNK
|
||||
|
||||
if chunk_idx == 4:
|
||||
return None, 'METADATA CHUNK'
|
||||
|
||||
phys = LV_START + grp*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
with open('/dev/sda','rb') as f:
|
||||
f.seek(phys)
|
||||
return f.read(INODE_SZ), 'OK'
|
||||
|
||||
def parse_inode(data):
|
||||
mode = struct.unpack_from('<H', data, 0)[0]
|
||||
size = struct.unpack_from('<I', data, 4)[0]
|
||||
mtime = struct.unpack_from('<I', data, 16)[0]
|
||||
links = struct.unpack_from('<H', data, 26)[0]
|
||||
flags = struct.unpack_from('<I', data, 32)[0]
|
||||
ext_magic = struct.unpack_from('<H', data, 40)[0]
|
||||
return mode, size, mtime, links, flags, ext_magic
|
||||
|
||||
# Check root inode and nearby system inodes
|
||||
for ino in [2, 3, 4, 5, 6, 7, 8, 11, 12]:
|
||||
data, status = read_inode(ino)
|
||||
if data is None:
|
||||
print(f'Inode {ino:4d}: {status}')
|
||||
continue
|
||||
mode, size, mtime, links, flags, ext_magic = parse_inode(data)
|
||||
ftype = {0x4000:'dir', 0x8000:'file', 0xa000:'link'}.get(mode&0xf000,'?')
|
||||
print(f'Inode {ino:4d}: type={ftype} mode=0x{mode:04x} '
|
||||
f'size={size} links={links} '
|
||||
f'extent_magic=0x{ext_magic:04x} '
|
||||
f'mtime={mtime} [{status}]')
|
||||
66
misc_tools/inspect_chunk.py
Normal file
66
misc_tools/inspect_chunk.py
Normal file
@@ -0,0 +1,66 @@
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# Block 5251104 falls in:
|
||||
# virt_byte = 5251104 * 4096 = 21508521984
|
||||
# group=65638, chunk_idx=4, intra=0
|
||||
|
||||
# The physical location of this metadata chunk:
|
||||
group = 65638
|
||||
phys_meta = LV_START + group*5*CHUNK + 4*CHUNK
|
||||
print(f'Metadata chunk physical byte: {phys_meta}')
|
||||
|
||||
# Read the full 64KB metadata chunk
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(phys_meta)
|
||||
meta = f.read(CHUNK)
|
||||
|
||||
nonzero = sum(1 for b in meta if b != 0)
|
||||
print(f'Nonzero bytes: {nonzero}/{CHUNK}')
|
||||
print()
|
||||
|
||||
# Earlier we found that metadata chunks store 512 bytes per sector
|
||||
# of the 4 adjacent data chunks = 128 sectors * 4 chunks = 512 entries
|
||||
# But what if SOME of those 512-byte slots contain filesystem data
|
||||
# instead of PERC metadata?
|
||||
|
||||
# The /var dir block should be 4096 bytes
|
||||
# Could it be stored in 8 consecutive 512-byte slots?
|
||||
# Let's look for directory signatures in the metadata chunk
|
||||
|
||||
import struct
|
||||
|
||||
# Search for directory entry signature: valid inode + rec_len + name_len
|
||||
print('Searching for directory entries in metadata chunk...')
|
||||
for off in range(0, CHUNK-8, 4):
|
||||
ino = struct.unpack_from('<I', meta, off)[0]
|
||||
rec_len = struct.unpack_from('<H', meta, off+4)[0]
|
||||
name_len= meta[off+6]
|
||||
ftype = meta[off+7]
|
||||
if (10 < ino < 500_000_000 and
|
||||
8 <= rec_len <= 256 and
|
||||
0 < name_len <= rec_len-8 and
|
||||
ftype in (1,2,7) and
|
||||
rec_len % 4 == 0):
|
||||
name = meta[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
if name.isprintable() and len(name) == name_len:
|
||||
print(f' offset {off}: inode={ino} rec_len={rec_len} '
|
||||
f'name={name!r} ftype={ftype}')
|
||||
|
||||
# Also check: does the metadata chunk contain the filesystem data
|
||||
# at a fixed offset? e.g. first 4096 bytes = filesystem data block?
|
||||
print()
|
||||
print('First 4096 bytes as directory:')
|
||||
off = 0
|
||||
while off < 4096-8:
|
||||
ino = struct.unpack_from('<I', meta, off)[0]
|
||||
rec_len = struct.unpack_from('<H', meta, off+4)[0]
|
||||
name_len= meta[off+6]
|
||||
ftype = meta[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = meta[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
off += rec_len
|
||||
91
misc_tools/inspect_disk.py
Normal file
91
misc_tools/inspect_disk.py
Normal file
@@ -0,0 +1,91 @@
|
||||
CHUNK = 128*512 # 64KB
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# Block 5251104, virtual byte = 5251104 * 4096 = 21508521984
|
||||
# group=65638, chunk_idx=4, intra=0
|
||||
|
||||
# Physical layout: each group of 5 virtual chunks occupies
|
||||
# 5*CHUNK bytes on the physical disk (md0)
|
||||
# group 65638 starts at:
|
||||
group = 65638
|
||||
group_phys_start = LV_START + group * 5 * CHUNK
|
||||
print(f'Group {group} physical start: {group_phys_start}')
|
||||
print()
|
||||
|
||||
import struct
|
||||
|
||||
with open('/dev/md0','rb') as f:
|
||||
# Read all 5 chunks of this group
|
||||
f.seek(group_phys_start)
|
||||
all_chunks = f.read(5*CHUNK)
|
||||
|
||||
for ci in range(5):
|
||||
chunk = all_chunks[ci*CHUNK:(ci+1)*CHUNK]
|
||||
nonzero = sum(1 for b in chunk if b != 0)
|
||||
# Try first 32 bytes as directory
|
||||
ino = struct.unpack_from('<I', chunk, 0)[0]
|
||||
rec = struct.unpack_from('<H', chunk, 4)[0]
|
||||
nl = chunk[6]
|
||||
ft = chunk[7]
|
||||
print(f'Chunk {ci}: nonzero={nonzero}/{CHUNK}', end='')
|
||||
if 10 < ino < 500_000_000 and 8 <= rec <= 256 and 0 < nl < 32 and ft in (1,2,7):
|
||||
name = chunk[8:8+nl].decode('utf-8',errors='replace')
|
||||
print(f' POSSIBLE DIR: inode={ino} name={name!r}', end='')
|
||||
print()
|
||||
|
||||
# The data chunks (0-3) map to virtual chunks as follows:
|
||||
# Our translation: virtual chunk N -> physical chunk N (for N<4)
|
||||
# So physical chunks 0,1,2,3 = virtual chunks 0,1,2,3
|
||||
# And physical chunk 4 = the metadata chunk we skip
|
||||
|
||||
# But what if our anchor points were right (FAT32 and LV)
|
||||
# yet the internal mapping within the LV is different?
|
||||
# What if the PERC uses a different chunk as metadata inside the LV?
|
||||
|
||||
# Our two anchors:
|
||||
# FAT32 VBR at PERC virtual 2048 -> md0 sector 1664
|
||||
# LV start at PERC virtual 6400000 -> md0 sector 5120000
|
||||
|
||||
# These are BEFORE the LV. Inside the LV, could the chunk order differ?
|
||||
# What if inside the LV the metadata chunk is chunk 0, not chunk 4?
|
||||
|
||||
# Test: read chunk 0 of group 65638 as directory
|
||||
chunk0 = all_chunks[0:CHUNK]
|
||||
print()
|
||||
print('Chunk 0 as directory:')
|
||||
off = 0
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', chunk0, off)[0]
|
||||
rec_len = struct.unpack_from('<H', chunk0, off+4)[0]
|
||||
name_len= chunk0[off+6]
|
||||
ftype = chunk0[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = chunk0[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
off += rec_len
|
||||
|
||||
# Test each chunk as potential /var directory
|
||||
print()
|
||||
for ci in range(5):
|
||||
chunk = all_chunks[ci*CHUNK:(ci+1)*CHUNK]
|
||||
off = 0
|
||||
entries = []
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', chunk, off)[0]
|
||||
rec_len = struct.unpack_from('<H', chunk, off+4)[0]
|
||||
name_len= chunk[off+6]
|
||||
ftype = chunk[off+7]
|
||||
if rec_len < 8: break
|
||||
if 10 < ino < 500_000_000 and name_len > 0 and ftype in (1,2,7):
|
||||
name = chunk[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
if name.isprintable():
|
||||
entries.append((ino, name, ftype))
|
||||
off += rec_len
|
||||
if entries:
|
||||
print(f'Chunk {ci} has {len(entries)} directory entries:')
|
||||
for ino, name, ftype in entries[:5]:
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
80
misc_tools/interleave_check.py
Normal file
80
misc_tools/interleave_check.py
Normal file
@@ -0,0 +1,80 @@
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# The directory data block is at virtual block 5251104
|
||||
# virt_byte = 5251104 * 4096 = 21508521984
|
||||
virt_byte = 5251104 * BSIZE
|
||||
|
||||
group = virt_byte // (5*CHUNK)
|
||||
in_group = virt_byte % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
|
||||
print(f'Virtual byte: {virt_byte}')
|
||||
print(f'Chunk group: {group}')
|
||||
print(f'Chunk index: {chunk_idx}')
|
||||
print(f'Intra: {intra}')
|
||||
print()
|
||||
|
||||
# This is chunk_idx=4 - a PERC metadata chunk
|
||||
# The PERC stored filesystem data here
|
||||
# Physical location:
|
||||
# In our translation, we skip chunk_idx=4
|
||||
# But if the PERC stored data there, it's at:
|
||||
# phys = LV_START + group*5*CHUNK + chunk_idx*CHUNK + intra
|
||||
# (5 chunks per group including metadata chunk)
|
||||
phys_with_meta = LV_START + group*5*CHUNK + chunk_idx*CHUNK + intra
|
||||
print(f'Physical byte if 5-chunk groups: {phys_with_meta}')
|
||||
print(f'Physical sector: {phys_with_meta//512}')
|
||||
|
||||
# Also try: physical = LV_START + group*4*CHUNK + ... but for chunk 4
|
||||
# In our current scheme chunk 4 has no mapping
|
||||
# But the data must be somewhere on disk
|
||||
|
||||
# The PERC presents 5 virtual chunks = 5*64KB = 320KB per group
|
||||
# Of these, 4 are data and 1 is PERC metadata
|
||||
# But what if it's the OPPOSITE?
|
||||
# What if 4 are PERC metadata and only 1 is data? No - we verified 4/5 ratio
|
||||
|
||||
# What if the metadata chunk IS stored on disk but at a different offset?
|
||||
# The PERC stores its own metadata in those chunk positions
|
||||
# For reading filesystem data that falls there, it must use the metadata chunk
|
||||
# storage differently
|
||||
|
||||
# Let's read what's physically at that location
|
||||
# The virtual block 5251104 is in chunk group 'group' at chunk position 4
|
||||
# In the physical layout (5 chunks per group):
|
||||
# group X physical layout: [data0][data1][data2][data3][meta4]
|
||||
# Our translation: physical chunk = group*4 + chunk_idx (skipping meta)
|
||||
# So physical chunk for data chunks 0-3 = group*4 + 0,1,2,3
|
||||
# And physical chunk for meta = stored AFTER the 4 data chunks = group*4+4?
|
||||
# But that would mean physical layout is: data0,data1,data2,data3,meta4
|
||||
# which IS 5 consecutive chunks on disk
|
||||
|
||||
# So the physical byte for chunk_idx=4 would be:
|
||||
phys_consecutive = LV_START + group*5*CHUNK + 4*CHUNK + intra
|
||||
print(f'Physical byte (consecutive 5-chunk): {phys_consecutive}')
|
||||
|
||||
# Read it
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(phys_consecutive)
|
||||
data = f.read(512)
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
print(f'Nonzero bytes: {nonzero}/512')
|
||||
print(f'First 32: {data[:32].hex()}')
|
||||
|
||||
# Also check: maybe the metadata chunk is interleaved differently
|
||||
# What if physical layout per group is: [meta0][data1][data2][data3][data4]?
|
||||
# i.e. metadata chunk is FIRST, not last?
|
||||
for meta_pos in range(5):
|
||||
# If metadata is at position meta_pos within each group of 5 physical chunks
|
||||
# Then for virtual chunk_idx=4 (the one we skip):
|
||||
# virtual chunks 0,1,2,3 map to physical chunks skipping meta_pos
|
||||
# virtual chunk 4 IS the metadata chunk
|
||||
phys_test = LV_START + group*5*CHUNK + meta_pos*CHUNK + intra
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(phys_test)
|
||||
data = f.read(64)
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
print(f'meta_pos={meta_pos}: phys={phys_test} nonzero={nonzero} first8={data[:8].hex()}')
|
||||
39
misc_tools/investigate_translation.py
Normal file
39
misc_tools/investigate_translation.py
Normal file
@@ -0,0 +1,39 @@
|
||||
CHUNK = 128*512 # 65536 bytes
|
||||
BSIZE = 4096
|
||||
|
||||
# Working blocks:
|
||||
# 9262 (root dir) -> chunk_idx=3, works
|
||||
# 6300141 (volumes) -> chunk_idx=3, works
|
||||
# 6299897 (ptero) -> chunk_idx=3, works
|
||||
|
||||
# Failing block:
|
||||
# 5251104 (var) -> chunk_idx=4, fails
|
||||
|
||||
# All working blocks have chunk_idx=3
|
||||
# The failing block has chunk_idx=4
|
||||
|
||||
# What if the issue isn't our translation formula
|
||||
# but that block 5251104 was written when the filesystem
|
||||
# was mounted through the PERC, and the PERC stored it
|
||||
# in a way we can't access without the controller?
|
||||
|
||||
# Let's find MORE blocks that work to see if chunk_idx matters:
|
||||
# Check the /boot, /home, /usr directories
|
||||
|
||||
import subprocess, struct
|
||||
|
||||
for path, inode in [('boot',2621441), ('home',2097153), ('usr',5505025),
|
||||
('etc',4456449), ('tmp',786433)]:
|
||||
r = subprocess.run(['istat','/dev/nbd0',str(inode)],
|
||||
capture_output=True, text=True)
|
||||
for line in r.stdout.splitlines():
|
||||
if 'Direct Blocks' in line:
|
||||
pass
|
||||
elif line.strip() and all(c.isdigit() or c==' ' for c in line.strip()):
|
||||
blocks = [int(x) for x in line.split() if x.strip()]
|
||||
for b in blocks:
|
||||
virt = b * BSIZE
|
||||
group = virt // (5*CHUNK)
|
||||
in_group = virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
print(f'/{path} block={b} chunk_idx={chunk_idx}')
|
||||
91
misc_tools/offset_check.py
Normal file
91
misc_tools/offset_check.py
Normal file
@@ -0,0 +1,91 @@
|
||||
CHUNK = 128*512 # 64KB
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# Block 5251104, virtual byte = 5251104 * 4096 = 21508521984
|
||||
# group=65638, chunk_idx=4, intra=0
|
||||
|
||||
# Physical layout: each group of 5 virtual chunks occupies
|
||||
# 5*CHUNK bytes on the physical disk (md0)
|
||||
# group 65638 starts at:
|
||||
group = 65638
|
||||
group_phys_start = LV_START + group * 5 * CHUNK
|
||||
print(f'Group {group} physical start: {group_phys_start}')
|
||||
print()
|
||||
|
||||
import struct
|
||||
|
||||
with open('/dev/md0','rb') as f:
|
||||
# Read all 5 chunks of this group
|
||||
f.seek(group_phys_start)
|
||||
all_chunks = f.read(5*CHUNK)
|
||||
|
||||
for ci in range(5):
|
||||
chunk = all_chunks[ci*CHUNK:(ci+1)*CHUNK]
|
||||
nonzero = sum(1 for b in chunk if b != 0)
|
||||
# Try first 32 bytes as directory
|
||||
ino = struct.unpack_from('<I', chunk, 0)[0]
|
||||
rec = struct.unpack_from('<H', chunk, 4)[0]
|
||||
nl = chunk[6]
|
||||
ft = chunk[7]
|
||||
print(f'Chunk {ci}: nonzero={nonzero}/{CHUNK}', end='')
|
||||
if 10 < ino < 500_000_000 and 8 <= rec <= 256 and 0 < nl < 32 and ft in (1,2,7):
|
||||
name = chunk[8:8+nl].decode('utf-8',errors='replace')
|
||||
print(f' POSSIBLE DIR: inode={ino} name={name!r}', end='')
|
||||
print()
|
||||
|
||||
# The data chunks (0-3) map to virtual chunks as follows:
|
||||
# Our translation: virtual chunk N -> physical chunk N (for N<4)
|
||||
# So physical chunks 0,1,2,3 = virtual chunks 0,1,2,3
|
||||
# And physical chunk 4 = the metadata chunk we skip
|
||||
|
||||
# But what if our anchor points were right (FAT32 and LV)
|
||||
# yet the internal mapping within the LV is different?
|
||||
# What if the PERC uses a different chunk as metadata inside the LV?
|
||||
|
||||
# Our two anchors:
|
||||
# FAT32 VBR at PERC virtual 2048 -> md0 sector 1664
|
||||
# LV start at PERC virtual 6400000 -> md0 sector 5120000
|
||||
|
||||
# These are BEFORE the LV. Inside the LV, could the chunk order differ?
|
||||
# What if inside the LV the metadata chunk is chunk 0, not chunk 4?
|
||||
|
||||
# Test: read chunk 0 of group 65638 as directory
|
||||
chunk0 = all_chunks[0:CHUNK]
|
||||
print()
|
||||
print('Chunk 0 as directory:')
|
||||
off = 0
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', chunk0, off)[0]
|
||||
rec_len = struct.unpack_from('<H', chunk0, off+4)[0]
|
||||
name_len= chunk0[off+6]
|
||||
ftype = chunk0[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = chunk0[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
off += rec_len
|
||||
|
||||
# Test each chunk as potential /var directory
|
||||
print()
|
||||
for ci in range(5):
|
||||
chunk = all_chunks[ci*CHUNK:(ci+1)*CHUNK]
|
||||
off = 0
|
||||
entries = []
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', chunk, off)[0]
|
||||
rec_len = struct.unpack_from('<H', chunk, off+4)[0]
|
||||
name_len= chunk[off+6]
|
||||
ftype = chunk[off+7]
|
||||
if rec_len < 8: break
|
||||
if 10 < ino < 500_000_000 and name_len > 0 and ftype in (1,2,7):
|
||||
name = chunk[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
if name.isprintable():
|
||||
entries.append((ino, name, ftype))
|
||||
off += rec_len
|
||||
if entries:
|
||||
print(f'Chunk {ci} has {len(entries)} directory entries:')
|
||||
for ino, name, ftype in entries[:5]:
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
93
misc_tools/raw_test.py
Normal file
93
misc_tools/raw_test.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
|
||||
def read_virt(virt_byte, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_byte
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK)
|
||||
in_group = pos % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
seg_len = min(CHUNK-intra, remaining)
|
||||
dst_off = pos - virt_byte
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
# Read GDT from md0
|
||||
print('Reading GDT from /dev/md0...')
|
||||
gdt_data = read_virt(BSIZE, 20*64)
|
||||
|
||||
for g in range(20):
|
||||
entry = gdt_data[g*64:(g+1)*64]
|
||||
it_lo = struct.unpack_from('<I', entry, 8)[0]
|
||||
it_hi = struct.unpack_from('<I', entry, 40)[0]
|
||||
it_block = (it_hi<<32)|it_lo
|
||||
flags = struct.unpack_from('<H', entry, 18)[0]
|
||||
print(f' Group {g:3d}: it_block={it_block:10d} '
|
||||
f'formula={1070+g*512} '
|
||||
f'match={it_block==1070+g*512} '
|
||||
f'flags=0x{flags:04x}')
|
||||
|
||||
# Read /var inode
|
||||
var_inode = 1310721
|
||||
var_group = (var_inode-1)//IPG
|
||||
var_idx = (var_inode-1)%IPG
|
||||
print(f'\n/var inode {var_inode}: group={var_group} idx={var_idx}')
|
||||
|
||||
gdt_entry = read_virt(BSIZE + var_group*64, 64)
|
||||
it_lo = struct.unpack_from('<I', gdt_entry, 8)[0]
|
||||
it_hi = struct.unpack_from('<I', gdt_entry, 40)[0]
|
||||
it_block = (it_hi<<32)|it_lo
|
||||
print(f'GDT it_block: {it_block}')
|
||||
|
||||
inode_virt = it_block*BSIZE + var_idx*256
|
||||
inode_data = read_virt(inode_virt, 256)
|
||||
mode = struct.unpack_from('<H', inode_data, 0)[0]
|
||||
size = struct.unpack_from('<I', inode_data, 4)[0]
|
||||
links = struct.unpack_from('<H', inode_data, 26)[0]
|
||||
mtime = struct.unpack_from('<I', inode_data, 16)[0]
|
||||
ext_magic = struct.unpack_from('<H', inode_data, 40)[0]
|
||||
print(f'mode=0x{mode:04x} size={size} links={links} '
|
||||
f'mtime={mtime} ext_magic=0x{ext_magic:04x}')
|
||||
print(f'Valid dir: {(mode&0xf000)==0x4000 and ext_magic==0xf30a}')
|
||||
|
||||
# If valid, list directory contents
|
||||
if (mode&0xf000)==0x4000 and ext_magic==0xf30a:
|
||||
print()
|
||||
print('Directory contents:')
|
||||
entries_raw = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
if depth == 0:
|
||||
for i in range(min(entries_raw,4)):
|
||||
off = 52+i*12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
blk = (ee_hi<<32)|ee_lo
|
||||
blk_data = read_virt(blk*BSIZE, BSIZE)
|
||||
off2 = 0
|
||||
while off2 < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', blk_data, off2)[0]
|
||||
rec_len = struct.unpack_from('<H', blk_data, off2+4)[0]
|
||||
name_len= blk_data[off2+6]
|
||||
ftype = blk_data[off2+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = blk_data[off2+8:off2+8+name_len].decode('utf-8',errors='replace')
|
||||
if name not in ('.','..'):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname:4s} inode={ino:10d} {name!r}')
|
||||
off2 += rec_len
|
||||
95
misc_tools/read_root.py
Normal file
95
misc_tools/read_root.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
INODE_SZ = 256
|
||||
|
||||
def phys_from_virt(virt):
|
||||
grp = virt//(5*CHUNK)
|
||||
in_group = virt%(5*CHUNK)
|
||||
chunk_idx= in_group//CHUNK
|
||||
intra = in_group%CHUNK
|
||||
if chunk_idx == 4: return None
|
||||
return LV_START + grp*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
|
||||
def read_phys(phys, length):
|
||||
with open('/dev/sda','rb') as f:
|
||||
f.seek(phys)
|
||||
return f.read(length)
|
||||
|
||||
def read_virt(virt, length):
|
||||
phys = phys_from_virt(virt)
|
||||
if phys is None: return b'\x00'*length
|
||||
return read_phys(phys, length)
|
||||
|
||||
def read_inode(inode_num):
|
||||
group = (inode_num-1)//IPG
|
||||
idx = (inode_num-1)%IPG
|
||||
it_block = 1070 + group*512
|
||||
virt = it_block*BSIZE + idx*INODE_SZ
|
||||
phys = phys_from_virt(virt)
|
||||
if phys is None: return None
|
||||
return read_phys(phys, INODE_SZ)
|
||||
|
||||
def get_extents(inode_data):
|
||||
blocks = []
|
||||
if struct.unpack_from('<H', inode_data, 40)[0] != 0xf30a:
|
||||
return blocks
|
||||
entries = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
if depth == 0:
|
||||
for i in range(min(entries,4)):
|
||||
off = 52+i*12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
start = (ee_hi<<32)|ee_lo
|
||||
for b in range(min(ee_len,8)):
|
||||
blocks.append(start+b)
|
||||
return blocks
|
||||
|
||||
def list_dir(inode_num):
|
||||
data = read_inode(inode_num)
|
||||
if not data: return []
|
||||
entries = []
|
||||
for blk in get_extents(data):
|
||||
blk_data = read_virt(blk*BSIZE, BSIZE)
|
||||
off = 0
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', blk_data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', blk_data, off+4)[0]
|
||||
name_len= blk_data[off+6]
|
||||
ftype = blk_data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = blk_data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
entries.append((ino, name, ftype))
|
||||
off += rec_len
|
||||
return entries
|
||||
|
||||
# List root directory
|
||||
print('Root directory (inode 2):')
|
||||
for ino, name, ftype in list_dir(2):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname:4s} {name:20s} inode={ino}')
|
||||
|
||||
# Find var
|
||||
print()
|
||||
for ino, name, ftype in list_dir(2):
|
||||
if name == 'var':
|
||||
print(f'Found /var at inode {ino}')
|
||||
print('Contents of /var:')
|
||||
for i2, n2, f2 in list_dir(ino):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(f2,'?')
|
||||
print(f' {tname:4s} {n2:20s} inode={i2}')
|
||||
|
||||
# Find var/lib
|
||||
for i2, n2, f2 in list_dir(ino):
|
||||
if n2 == 'lib':
|
||||
print(f'\nFound /var/lib at inode {i2}')
|
||||
print('Contents of /var/lib:')
|
||||
for i3, n3, f3 in list_dir(i2):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(f3,'?')
|
||||
print(f' {tname:4s} {n3:20s} inode={i3}')
|
||||
70
misc_tools/search_var.py
Normal file
70
misc_tools/search_var.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# Search for /var directory block by looking for its known contents
|
||||
# /var should contain: lib, log, cache, spool, tmp, www, etc.
|
||||
# inode 1310721 with links=15 means 15 entries
|
||||
|
||||
# The directory block must contain entry for 'lib' pointing to a valid inode
|
||||
# Let's search all of md0 for a 4KB block containing 'lib' as a dir entry
|
||||
# where the parent context suggests it's /var
|
||||
|
||||
targets = [b'log', b'cache', b'spool', b'backups', b'mail']
|
||||
|
||||
print('Searching for /var directory block...')
|
||||
print('Looking for block containing multiple /var subdirectory names')
|
||||
|
||||
chunk_size = 32*1024*1024
|
||||
offset = LV_START # start from filesystem area
|
||||
|
||||
found_blocks = {}
|
||||
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(0,2)
|
||||
disk_size = f.tell()
|
||||
|
||||
f.seek(offset)
|
||||
pos = offset
|
||||
while pos < disk_size:
|
||||
data = f.read(min(chunk_size, disk_size-pos))
|
||||
if not data: break
|
||||
|
||||
# Look for blocks containing multiple target strings
|
||||
for blk_off in range(0, len(data)-BSIZE, BSIZE):
|
||||
block = data[blk_off:blk_off+BSIZE]
|
||||
matches = sum(1 for t in targets if t in block)
|
||||
if matches >= 2:
|
||||
abs_byte = pos + blk_off
|
||||
# Verify as directory block
|
||||
entries = []
|
||||
off = 0
|
||||
while off < BSIZE-8:
|
||||
ino = struct.unpack_from('<I', block, off)[0]
|
||||
rec_len = struct.unpack_from('<H', block, off+4)[0]
|
||||
name_len= block[off+6]
|
||||
ftype = block[off+7]
|
||||
if rec_len < 8: break
|
||||
if (10 < ino < 500_000_000 and
|
||||
0 < name_len <= rec_len-8 and
|
||||
ftype in (1,2,7) and
|
||||
rec_len % 4 == 0):
|
||||
name = block[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
if name.isprintable():
|
||||
entries.append((ino,name,ftype))
|
||||
off += rec_len
|
||||
|
||||
if len(entries) >= 3:
|
||||
abs_block = (abs_byte - LV_START) // BSIZE
|
||||
print(f'Candidate at md0 byte {abs_byte} '
|
||||
f'(block {abs_block}):')
|
||||
for ino,name,ftype in entries[:10]:
|
||||
tname={1:\"file\",2:\"dir\",7:\"link\"}.get(ftype,'?')
|
||||
print(f' {tname} inode={ino} {name!r}')
|
||||
print()
|
||||
|
||||
pos += chunk_size
|
||||
if pos % (10*1024**3) == 0:
|
||||
print(f' Scanned {pos//1024**3}GB...', flush=True)
|
||||
54
misc_tools/stump.py
Normal file
54
misc_tools/stump.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
BPG = 32768
|
||||
|
||||
def read_virt(virt_byte, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_byte
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK)
|
||||
in_group = pos % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
seg_len = min(CHUNK-intra, remaining)
|
||||
dst_off = pos - virt_byte
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
# Load GDT
|
||||
NUM_GROUPS = 35728
|
||||
gdt_data = read_virt(BSIZE, NUM_GROUPS*64)
|
||||
|
||||
def get_block_bitmap_block(group):
|
||||
entry = gdt_data[group*64:(group+1)*64]
|
||||
bb_lo = struct.unpack_from('<I', entry, 0)[0]
|
||||
bb_hi = struct.unpack_from('<I', entry, 32)[0]
|
||||
return (bb_hi<<32)|bb_lo
|
||||
|
||||
def is_block_allocated(block_num):
|
||||
group = block_num // BPG
|
||||
offset = block_num % BPG
|
||||
bb_block = get_block_bitmap_block(group)
|
||||
bitmap = read_virt(bb_block*BSIZE, BSIZE)
|
||||
byte_idx = offset // 8
|
||||
bit_idx = offset % 8
|
||||
return bool(bitmap[byte_idx] & (1 << bit_idx))
|
||||
|
||||
for block, name in [(6300141,'volumes'), (6299897,'pterodactyl'), (5251104,'var')]:
|
||||
allocated = is_block_allocated(block)
|
||||
virt = block * BSIZE
|
||||
in_group = virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
print(f'{name:15s} block={block:8d} chunk_idx={chunk_idx} '
|
||||
f'allocated={allocated}')
|
||||
73
misc_tools/test1.py
Normal file
73
misc_tools/test1.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
INODE_SZ = 256
|
||||
|
||||
def phys_from_virt(virt):
|
||||
grp = virt//(5*CHUNK)
|
||||
in_group = virt%(5*CHUNK)
|
||||
chunk_idx= in_group//CHUNK
|
||||
intra = in_group%CHUNK
|
||||
if chunk_idx == 4: return None
|
||||
return LV_START + grp*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
|
||||
def read_inode(inode_num):
|
||||
group = (inode_num-1)//IPG
|
||||
idx = (inode_num-1)%IPG
|
||||
it_block = 1070 + group*512
|
||||
virt = it_block*BSIZE + idx*INODE_SZ
|
||||
print(f' inode {inode_num}: group={group} idx={idx}')
|
||||
print(f' inode table block: {it_block}')
|
||||
print(f' virtual byte: {virt}')
|
||||
phys = phys_from_virt(virt)
|
||||
print(f' physical byte: {phys}')
|
||||
if phys is None:
|
||||
print(f' IN METADATA CHUNK')
|
||||
return None
|
||||
with open('/dev/sda','rb') as f:
|
||||
f.seek(phys)
|
||||
return f.read(INODE_SZ)
|
||||
|
||||
# Check known good inode - volumes directory
|
||||
print('=== Inode 1585918 (volumes dir) ===')
|
||||
data = read_inode(1585918)
|
||||
if data:
|
||||
mode = struct.unpack_from('<H', data, 0)[0]
|
||||
size = struct.unpack_from('<I', data, 4)[0]
|
||||
links = struct.unpack_from('<H', data, 26)[0]
|
||||
mtime = struct.unpack_from('<I', data, 16)[0]
|
||||
ext_magic = struct.unpack_from('<H', data, 40)[0]
|
||||
print(f' mode=0x{mode:04x} size={size} links={links}')
|
||||
print(f' mtime={mtime} ext_magic=0x{ext_magic:04x}')
|
||||
print(f' valid dir: {(mode&0xf000)==0x4000 and ext_magic==0xf30a}')
|
||||
|
||||
# Now check inode 2 via NBD (which applies translation correctly)
|
||||
print()
|
||||
print('=== Inode 2 via NBD device ===')
|
||||
group = (2-1)//IPG
|
||||
idx = (2-1)%IPG
|
||||
it_block = 1070 + group*512
|
||||
virt = it_block*BSIZE + idx*INODE_SZ
|
||||
print(f' virtual byte: {virt}')
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(virt)
|
||||
data = f.read(INODE_SZ)
|
||||
mode = struct.unpack_from('<H', data, 0)[0]
|
||||
links = struct.unpack_from('<H', data, 26)[0]
|
||||
mtime = struct.unpack_from('<I', data, 16)[0]
|
||||
ext_magic = struct.unpack_from('<H', data, 40)[0]
|
||||
print(f' mode=0x{mode:04x} links={links} mtime={mtime}')
|
||||
print(f' ext_magic=0x{ext_magic:04x}')
|
||||
print(f' first 32 bytes: {data[:32].hex()}')
|
||||
|
||||
# Compare: what does debugfs think inode 2 contains?
|
||||
print()
|
||||
print('=== What istat says about inode 2 ===')
|
||||
import subprocess
|
||||
r = subprocess.run(['istat', '/dev/nbd0', '2'],
|
||||
capture_output=True, text=True)
|
||||
print(r.stdout[:500])
|
||||
print(r.stderr[:200])
|
||||
55
misc_tools/test2.py
Normal file
55
misc_tools/test2.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Inode 2 is at virtual byte 4382976 via NBD
|
||||
# Group 0 inode table should be at block = 4382976 // 4096 = 1069 or 1070?
|
||||
virt = 4382976
|
||||
BSIZE = 4096
|
||||
block = virt // BSIZE
|
||||
offset_in_block = virt % BSIZE
|
||||
inode_idx = offset_in_block // 256
|
||||
|
||||
print(f'Inode 2 virtual byte: {virt}')
|
||||
print(f'Block: {block}')
|
||||
print(f'Offset in block: {offset_in_block}')
|
||||
print(f'Inode index in block: {inode_idx}')
|
||||
|
||||
# istat said direct block = 9262 for root dir data
|
||||
# That means the root directory data is at block 9262
|
||||
# Let's read that directly via NBD
|
||||
import struct
|
||||
|
||||
BSIZE = 4096
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(9262 * BSIZE)
|
||||
data = f.read(BSIZE)
|
||||
|
||||
print()
|
||||
print('Root directory data block (block 9262):')
|
||||
off = 0
|
||||
while off < BSIZE - 8:
|
||||
ino = struct.unpack_from('<I', data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', data, off+4)[0]
|
||||
name_len= data[off+6]
|
||||
ftype = data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname:4s} inode={ino:10d} {name!r}')
|
||||
off += rec_len
|
||||
|
||||
# Now check: our formula says group 0 inode table is at block 1070
|
||||
# But inode 2 is at virtual byte 4382976 = block 1069.something?
|
||||
print()
|
||||
print(f'Our formula block: 1070')
|
||||
print(f'Actual block from NBD: {virt//BSIZE} (byte {virt})')
|
||||
print(f'Difference: {1070 - virt//BSIZE}')
|
||||
|
||||
# The key question: is our GDT formula wrong?
|
||||
# GDT says inode table at block 1070 for group 0
|
||||
# But inode 2 (second inode) is at:
|
||||
# block 1070, offset 256 bytes (one inode size)
|
||||
# = byte 1070*4096 + 256 = 4382976 + 256... wait
|
||||
print()
|
||||
print(f'1070 * 4096 = {1070*4096}')
|
||||
print(f'1070 * 4096 + 256 = {1070*4096+256}')
|
||||
print(f'Actual inode 2 virt byte from NBD: {virt}')
|
||||
print(f'Match: {1070*4096+256 == virt}')
|
||||
75
misc_tools/test3.py
Normal file
75
misc_tools/test3.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import struct
|
||||
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
|
||||
def read_block(f, block_num):
|
||||
f.seek(block_num * BSIZE)
|
||||
return f.read(BSIZE)
|
||||
|
||||
def get_extents(inode_data):
|
||||
blocks = []
|
||||
if struct.unpack_from('<H', inode_data, 40)[0] != 0xf30a:
|
||||
return blocks
|
||||
entries = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
off = 52 + i*12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
start = (ee_hi<<32)|ee_lo
|
||||
for b in range(min(ee_len, 8)):
|
||||
blocks.append(start + b)
|
||||
return blocks
|
||||
|
||||
def read_inode(f, inode_num):
|
||||
group = (inode_num-1)//IPG
|
||||
idx = (inode_num-1)%IPG
|
||||
it_block = 1070 + group*512
|
||||
f.seek(it_block*BSIZE + idx*256)
|
||||
return f.read(256)
|
||||
|
||||
def list_dir(f, inode_num):
|
||||
data = read_inode(f, inode_num)
|
||||
entries = []
|
||||
for blk in get_extents(data):
|
||||
blk_data = read_block(f, blk)
|
||||
off = 0
|
||||
while off < BSIZE - 8:
|
||||
ino = struct.unpack_from('<I', blk_data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', blk_data, off+4)[0]
|
||||
name_len= blk_data[off+6]
|
||||
ftype = blk_data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = blk_data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
if name not in ('.','..'):
|
||||
entries.append((ino, name, ftype))
|
||||
off += rec_len
|
||||
return entries
|
||||
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
# /var = inode 1310721
|
||||
print('=== /var (inode 1310721) ===')
|
||||
for ino, name, ftype in list_dir(f, 1310721):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f' {tname:4s} {name:30s} inode={ino}')
|
||||
|
||||
# Find /var/lib
|
||||
print()
|
||||
for ino, name, ftype in list_dir(f, 1310721):
|
||||
if name == 'lib':
|
||||
print(f'=== /var/lib (inode {ino}) ===')
|
||||
for i2, n2, f2 in list_dir(f, ino):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(f2,'?')
|
||||
print(f' {tname:4s} {n2:30s} inode={i2}')
|
||||
|
||||
# Find pterodactyl
|
||||
for i2, n2, f2 in list_dir(f, ino):
|
||||
if n2 == 'pterodactyl':
|
||||
print(f'\n=== /var/lib/pterodactyl (inode {i2}) ===')
|
||||
for i3, n3, f3 in list_dir(f, i2):
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(f3,'?')
|
||||
print(f' {tname:4s} {n3:30s} inode={i3}')
|
||||
50
misc_tools/test_corruption.py
Normal file
50
misc_tools/test_corruption.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import struct
|
||||
|
||||
# Group 0 inode table at block 1070
|
||||
# Virtual byte: 1070 * 4096 = 4,382,720
|
||||
# Physical byte via chunk translation:
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
it_virt = 1070 * BSIZE
|
||||
group = it_virt // (5*CHUNK)
|
||||
in_group = it_virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
|
||||
print(f'Group 0 inode table:')
|
||||
print(f' Virtual byte: {it_virt}')
|
||||
print(f' Chunk group: {group}')
|
||||
print(f' Chunk index: {chunk_idx} (4=metadata)')
|
||||
print(f' Is metadata: {chunk_idx == 4}')
|
||||
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
print(f' Physical byte: {phys}')
|
||||
with open('/dev/sda','rb') as f:
|
||||
f.seek(phys)
|
||||
data = f.read(256)
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
print(f' First inode nonzero bytes: {nonzero}/256')
|
||||
print(f' First 32 bytes: {data[:32].hex()}')
|
||||
else:
|
||||
print(f' In PERC metadata chunk - reads as zeros')
|
||||
|
||||
# Check groups 0-15
|
||||
print()
|
||||
print('Checking which groups have inode tables in metadata chunks:')
|
||||
for g in range(20):
|
||||
it_block = 1070 + g*512
|
||||
it_virt = it_block * BSIZE
|
||||
in_group = it_virt % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
if chunk_idx == 4:
|
||||
print(f' Group {g:2d}: inode table in METADATA CHUNK (reads as zeros)')
|
||||
else:
|
||||
phys = LV_START + (it_virt//(5*CHUNK))*4*CHUNK + chunk_idx*CHUNK + (it_virt%CHUNK)
|
||||
with open('/dev/sda','rb') as f:
|
||||
f.seek(phys)
|
||||
data = f.read(256)
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
print(f' Group {g:2d}: DATA chunk, nonzero={nonzero}/256')
|
||||
59
misc_tools/verify_mapping.py
Normal file
59
misc_tools/verify_mapping.py
Normal file
@@ -0,0 +1,59 @@
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
|
||||
# Root dir data block = 9262
|
||||
# We read this successfully, so our translation works for it
|
||||
# Let's verify: block 9262 as filesystem block (offset from LV start)
|
||||
# vs block 9262 as PERC virtual block
|
||||
|
||||
fs_virt_byte = 9262 * BSIZE # filesystem block offset from LV start
|
||||
perc_virt_byte = 9262 * BSIZE # same number, different interpretation
|
||||
|
||||
# As filesystem block (what our NBD server does):
|
||||
# The NBD server presents a virtual address space where block N = byte N*4096
|
||||
# And applies the 4/5 chunk translation to convert to physical
|
||||
fs_group = fs_virt_byte // (5*CHUNK)
|
||||
fs_in_group = fs_virt_byte % (5*CHUNK)
|
||||
fs_chunk_idx = fs_in_group // CHUNK
|
||||
fs_intra = fs_in_group % CHUNK
|
||||
|
||||
print(f'Block 9262 as filesystem virtual (via NBD translation):')
|
||||
print(f' virt_byte={fs_virt_byte}')
|
||||
print(f' chunk_group={fs_group} chunk_idx={fs_chunk_idx} intra={fs_intra}')
|
||||
if fs_chunk_idx != 4:
|
||||
phys = LV_START + fs_group*4*CHUNK + fs_chunk_idx*CHUNK + fs_intra
|
||||
print(f' physical={phys}')
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(phys)
|
||||
data = f.read(64)
|
||||
print(f' first 32: {data[:32].hex()}')
|
||||
|
||||
# We know from istat that block 9262 contains the root directory
|
||||
# and we verified it has valid directory entries
|
||||
# So the block numbers ARE filesystem-level, and our translation IS correct
|
||||
|
||||
# Now: why does block 5251104 (var dir data) fall in a metadata chunk?
|
||||
# 5251104 * 4096 = 21508521984
|
||||
# group = 21508521984 // (5*65536) = 65638
|
||||
# in_group = 21508521984 % (5*65536) = 262144 = 4*65536
|
||||
# chunk_idx = 4 -> METADATA
|
||||
|
||||
# This means the /var directory data genuinely falls in a PERC metadata slot
|
||||
# The PERC would have stored its OWN metadata there
|
||||
# and the filesystem data for block 5251104 would need to be
|
||||
# at a DIFFERENT physical location
|
||||
|
||||
# Unless... the ext4 filesystem uses block numbers differently
|
||||
# In ext4 with flex_bg, block numbers might be relative to something else
|
||||
|
||||
# Let's check: what block does istat say for /var?
|
||||
# We need to run istat on inode 1310721
|
||||
import subprocess
|
||||
r = subprocess.run(['istat', '/dev/nbd0', '1310721'],
|
||||
capture_output=True, text=True)
|
||||
print()
|
||||
print('istat output for /var (inode 1310721):')
|
||||
print(r.stdout)
|
||||
print(r.stderr[:200] if r.stderr else '')
|
||||
|
||||
2
orphan_roots.txt
Normal file
2
orphan_roots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
786433 2 active parent-in-zeroed-region
|
||||
1048577 2 active parent-in-zeroed-region
|
||||
24904
orphans2.txt
Normal file
24904
orphans2.txt
Normal file
File diff suppressed because it is too large
Load Diff
117
restore_meta.py
Executable file
117
restore_meta.py
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stage 4 – Restore filesystem metadata (permissions, ownership, timestamps) to
|
||||
an already-extracted directory tree.
|
||||
|
||||
Run this after extract_tree.py if you did not use --restore-meta there, or to
|
||||
re-apply metadata without re-extracting files.
|
||||
|
||||
Usage:
|
||||
python3 restore_meta.py [options] <inode> <dest_dir>
|
||||
|
||||
Options:
|
||||
--device DEV Block device [/dev/dm-0]
|
||||
--backup-sb BLOCK Backup superblock block number [32768]
|
||||
--db PATH Optional: read metadata from SQLite DB instead of
|
||||
re-reading the device (faster) [inodes.db if present]
|
||||
"""
|
||||
import argparse, ctypes, ctypes.util, os, stat, sys
|
||||
import ext4lib
|
||||
|
||||
DEFAULT_DEV = '/dev/dm-0'
|
||||
DEFAULT_BACKUP_SB = 32768
|
||||
|
||||
# ── libc lutimes ──────────────────────────────────────────────────────────────
|
||||
|
||||
_libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True)
|
||||
|
||||
class _Timeval(ctypes.Structure):
|
||||
_fields_ = [('tv_sec', ctypes.c_long), ('tv_usec', ctypes.c_long)]
|
||||
|
||||
def _lutimes(path, atime, mtime):
|
||||
times = (_Timeval * 2)((_Timeval(atime, 0)), (_Timeval(mtime, 0)))
|
||||
_libc.lutimes(path.encode(), ctypes.byref(times))
|
||||
|
||||
|
||||
# ── per-path restore ──────────────────────────────────────────────────────────
|
||||
|
||||
def _apply_meta(dest_path, perms, uid, gid, atime, mtime):
|
||||
is_link = os.path.islink(dest_path)
|
||||
try:
|
||||
os.lchown(dest_path, uid, gid)
|
||||
except OSError:
|
||||
pass
|
||||
if not is_link:
|
||||
try:
|
||||
os.chmod(dest_path, perms)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_lutimes(dest_path, atime, mtime)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def restore_one(f, sb, gdt_data, inum, dest_path):
|
||||
try:
|
||||
idata, slot = ext4lib.read_inode(f, sb, gdt_data, inum)
|
||||
perms, uid, gid, atime, mtime = ext4lib.get_inode_meta(idata, slot, sb)
|
||||
_apply_meta(dest_path, perms, uid, gid, atime, mtime)
|
||||
except Exception as e:
|
||||
print(f" WARN {dest_path}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
# ── recursive walker ──────────────────────────────────────────────────────────
|
||||
|
||||
def walk_and_restore(f, sb, gdt_data, inum, dest_dir, visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if inum in visited:
|
||||
return
|
||||
visited.add(inum)
|
||||
|
||||
# Restore the directory itself first (to set ownership before descending)
|
||||
restore_one(f, sb, gdt_data, inum, dest_dir)
|
||||
|
||||
try:
|
||||
entries = ext4lib.read_dir_entries(f, sb, gdt_data, inum)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for name, (child_inum, ftype) in entries.items():
|
||||
if name in ('.', '..'):
|
||||
continue
|
||||
safe_name = name.replace('/', '_').replace('\x00', '')
|
||||
dest = os.path.join(dest_dir, safe_name)
|
||||
|
||||
if not os.path.lexists(dest):
|
||||
continue
|
||||
|
||||
if os.path.isdir(dest) and not os.path.islink(dest):
|
||||
walk_and_restore(f, sb, gdt_data, child_inum, dest, visited)
|
||||
else:
|
||||
restore_one(f, sb, gdt_data, child_inum, dest)
|
||||
|
||||
# Re-apply directory timestamps after children are written
|
||||
restore_one(f, sb, gdt_data, inum, dest_dir)
|
||||
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Restore ext4 metadata to extracted tree (Stage 4)')
|
||||
parser.add_argument('inode', type=int)
|
||||
parser.add_argument('dest_dir')
|
||||
parser.add_argument('--device', default=DEFAULT_DEV)
|
||||
parser.add_argument('--backup-sb', type=int, default=DEFAULT_BACKUP_SB)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.device, 'rb') as f:
|
||||
sb, gdt_data, _ = ext4lib.load_fs(f, args.backup_sb)
|
||||
print(f"Restoring metadata: inode {args.inode} → {args.dest_dir}")
|
||||
walk_and_restore(f, sb, gdt_data, args.inode, args.dest_dir)
|
||||
print("Done")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
135
scan_inodes.py
Executable file
135
scan_inodes.py
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stage 1 – Scan ext4 inode tables and persist everything to a SQLite database.
|
||||
|
||||
This is the slow stage that reads the raw device sequentially. Run it once;
|
||||
subsequent pipeline stages read from the database and never touch the device.
|
||||
|
||||
The scan is resumable: already-scanned groups are skipped on re-run.
|
||||
|
||||
Usage:
|
||||
python3 scan_inodes.py [options]
|
||||
|
||||
Options:
|
||||
--device DEV Block device or image file [/dev/dm-0]
|
||||
--backup-sb BLOCK Block number of the backup superblock [32768]
|
||||
--db PATH Output SQLite database [inodes.db]
|
||||
--zeroed-groups N First N block groups are damaged/zeroed (skip scan,
|
||||
mark parents in them as orphan triggers) [13]
|
||||
--start-group N Start scanning from group N (override resume logic)
|
||||
--end-group N Stop after group N (for partial scans / testing)
|
||||
"""
|
||||
import argparse, sys, time
|
||||
import ext4lib
|
||||
import ext4db
|
||||
|
||||
DEFAULT_DEV = '/dev/dm-0'
|
||||
DEFAULT_BACKUP_SB = 32768
|
||||
COMMIT_INTERVAL = 20 # commit every N groups
|
||||
|
||||
|
||||
def scan_group(f, sb, gdt_data, grp, db):
|
||||
inode_table_block = ext4lib.parse_gdt_entry(
|
||||
gdt_data, grp * sb['desc_size'], sb['desc_size'])
|
||||
if inode_table_block == 0:
|
||||
ext4db.mark_group_scanned(db, grp)
|
||||
return 0
|
||||
|
||||
inode_size = sb['inode_size']
|
||||
inodes_per_block = ext4lib.BLOCK // inode_size
|
||||
num_inode_blocks = (sb['inodes_per_group'] * inode_size + ext4lib.BLOCK - 1) // ext4lib.BLOCK
|
||||
|
||||
found = 0
|
||||
for blk_off in range(num_inode_blocks):
|
||||
try:
|
||||
idata = ext4lib.read_at(
|
||||
f, (inode_table_block + blk_off) * ext4lib.BLOCK, ext4lib.BLOCK)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for slot_idx in range(inodes_per_block):
|
||||
ino_off = slot_idx * inode_size
|
||||
abs_inum = grp * sb['inodes_per_group'] + blk_off * inodes_per_block + slot_idx + 1
|
||||
|
||||
inode = ext4lib.parse_inode_full(idata, ino_off, sb)
|
||||
if inode is None or inode['mode'] == 0:
|
||||
continue
|
||||
|
||||
status = ext4lib.classify_inode(idata, ino_off)
|
||||
ext4db.save_inode(db, abs_inum, grp, inode, status)
|
||||
found += 1
|
||||
|
||||
# For directories with links, also read dir entries from disk
|
||||
if inode['type'] == ext4lib.ITYPE_DIR and inode['links'] > 0:
|
||||
try:
|
||||
entries = ext4lib.read_dir_entries_raw(f, idata, ino_off)
|
||||
for name, (child_inum, ftype) in entries.items():
|
||||
ext4db.save_dir_entry(db, abs_inum, name, child_inum, ftype)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ext4db.mark_group_scanned(db, grp)
|
||||
return found
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Scan ext4 inodes into SQLite DB (Stage 1)')
|
||||
parser.add_argument('--device', default=DEFAULT_DEV)
|
||||
parser.add_argument('--backup-sb', type=int, default=DEFAULT_BACKUP_SB)
|
||||
parser.add_argument('--db', default='inodes.db')
|
||||
parser.add_argument('--zeroed-groups', type=int, default=13,
|
||||
help='First N groups are damaged; skip scan, flag parents there as orphans')
|
||||
parser.add_argument('--start-group', type=int, default=None,
|
||||
help='Force start at this group (ignores resume state)')
|
||||
parser.add_argument('--end-group', type=int, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
db = ext4db.open_db(args.db)
|
||||
|
||||
with open(args.device, 'rb') as f:
|
||||
sb, gdt_data, num_groups = ext4lib.load_fs(f, args.backup_sb)
|
||||
|
||||
print(f"Geometry: {sb['blocks_per_group']} blk/grp, "
|
||||
f"{sb['inodes_per_group']} ino/grp, "
|
||||
f"inode_size={sb['inode_size']}, desc_size={sb['desc_size']}")
|
||||
print(f"Total groups: {num_groups} | zeroed groups: 0–{args.zeroed_groups - 1}")
|
||||
|
||||
ext4db.save_fs_meta(db, sb, args.device, args.backup_sb, args.zeroed_groups)
|
||||
|
||||
scanned = ext4db.get_scanned_groups(db)
|
||||
end = args.end_group if args.end_group is not None else num_groups
|
||||
|
||||
if args.start_group is not None:
|
||||
start = args.start_group
|
||||
else:
|
||||
start = args.zeroed_groups
|
||||
|
||||
total_found = 0
|
||||
t0 = time.monotonic()
|
||||
|
||||
for grp in range(start, end):
|
||||
if grp in scanned:
|
||||
continue
|
||||
|
||||
found = scan_group(f, sb, gdt_data, grp, db)
|
||||
total_found += found
|
||||
|
||||
if grp % COMMIT_INTERVAL == 0:
|
||||
db.commit()
|
||||
|
||||
if grp % 100 == 0:
|
||||
elapsed = time.monotonic() - t0
|
||||
rate = (grp - start + 1) / elapsed if elapsed > 0 else 0
|
||||
eta = (end - grp) / rate if rate > 0 else 0
|
||||
print(f" group {grp:6d}/{end} inodes={total_found:,} "
|
||||
f"{rate:.1f} grp/s ETA {eta:.0f}s",
|
||||
end='\r', flush=True)
|
||||
|
||||
db.commit()
|
||||
|
||||
print(f"\nScan complete.")
|
||||
ext4db.print_stats(db)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1
test/INTERESTING_INODES
Normal file
1
test/INTERESTING_INODES
Normal file
@@ -0,0 +1 @@
|
||||
3544785:etc directory (blech... from a container)
|
||||
BIN
test/__pycache__/ext4lib.cpython-312.pyc
Normal file
BIN
test/__pycache__/ext4lib.cpython-312.pyc
Normal file
Binary file not shown.
BIN
test/__pycache__/inspect.cpython-312.pyc
Normal file
BIN
test/__pycache__/inspect.cpython-312.pyc
Normal file
Binary file not shown.
73
test/aa.sh
Normal file
73
test/aa.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
python3 -c "
|
||||
import struct
|
||||
|
||||
# Read superblock
|
||||
with open('/dev/md0','rb') as f:
|
||||
f.seek(2621441024) # physical primary sb
|
||||
sb = f.read(1024)
|
||||
|
||||
total_blocks = struct.unpack_from('<I', sb, 4)[0]
|
||||
bpg = struct.unpack_from('<I', sb, 40)[0]
|
||||
bsize = 4096
|
||||
num_groups = (total_blocks + bpg - 1) // bpg
|
||||
gdt_blocks = (num_groups * 64 + bsize - 1) // bsize # 64 bytes per GDT entry
|
||||
|
||||
print(f'Total blocks: {total_blocks}')
|
||||
print(f'Blocks/group: {bpg}')
|
||||
print(f'Num groups: {num_groups}')
|
||||
print(f'GDT size: {num_groups * 64} bytes = {gdt_blocks} blocks')
|
||||
print(f'GDT spans blocks 1 to {gdt_blocks}')
|
||||
print()
|
||||
|
||||
# Check which physical chunks cover the GDT
|
||||
CHUNK_BYTES = 128 * 512 # 64KB
|
||||
LV_PHYS_START = 5120000 * 512
|
||||
|
||||
def v_to_p_byte(virt_byte):
|
||||
group = virt_byte // (5 * CHUNK_BYTES)
|
||||
offset_in_group = virt_byte % (5 * CHUNK_BYTES)
|
||||
chunk_in_group = offset_in_group // CHUNK_BYTES
|
||||
intra = offset_in_group % CHUNK_BYTES
|
||||
if chunk_in_group == 4:
|
||||
return None # metadata chunk
|
||||
phys = (LV_PHYS_START +
|
||||
group * 4 * CHUNK_BYTES +
|
||||
chunk_in_group * CHUNK_BYTES +
|
||||
intra)
|
||||
return phys
|
||||
|
||||
# GDT spans virtual bytes 4096 to 4096+gdt_blocks*4096
|
||||
gdt_start_v = 4096 # block 1
|
||||
gdt_end_v = 4096 + gdt_blocks * 4096
|
||||
|
||||
print(f'GDT virtual bytes: {gdt_start_v} to {gdt_end_v}')
|
||||
print()
|
||||
|
||||
# Check each chunk that covers the GDT
|
||||
print('Chunks covering GDT:')
|
||||
pos = gdt_start_v
|
||||
while pos < gdt_end_v:
|
||||
chunk_in_group = (pos % (5 * CHUNK_BYTES)) // CHUNK_BYTES
|
||||
phys = v_to_p_byte(pos)
|
||||
chunk_end = pos + CHUNK_BYTES - (pos % CHUNK_BYTES)
|
||||
print(f' Virtual {pos}-{min(chunk_end,gdt_end_v)}: '
|
||||
f'chunk_type={chunk_in_group} '
|
||||
f'physical={phys} '
|
||||
f'{\"METADATA-LOST\" if phys is None else \"\"}')
|
||||
pos = chunk_end
|
||||
|
||||
# Read GDT from nbd and check first few entries
|
||||
print()
|
||||
print('GDT entries via NBD:')
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(4096)
|
||||
gdt_data = f.read(min(gdt_blocks * 4096, 65536))
|
||||
|
||||
for i in range(min(10, num_groups)):
|
||||
entry = gdt_data[i*64:(i+1)*64]
|
||||
bb = struct.unpack_from('<I', entry, 0)[0]
|
||||
ib = struct.unpack_from('<I', entry, 4)[0]
|
||||
it = struct.unpack_from('<I', entry, 8)[0]
|
||||
cs = struct.unpack_from('<H', entry, 30)[0]
|
||||
print(f' Group {i}: bb={bb} ib={ib} it={it} csum=0x{cs:04x}')
|
||||
"
|
||||
10
test/batch_recover.sh
Normal file
10
test/batch_recover.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
while read inum rest; do
|
||||
mkdir -p /mnt/recovered/apr29/${inum}
|
||||
python3 dump_tree.py ${inum} /mnt/recovered/apr29/${inum}/ &
|
||||
# Limit to 10 parallel jobs
|
||||
while (( $(jobs -r | wc -l) >= 4 )); do
|
||||
wait -n 2>/dev/null || sleep 0.2
|
||||
done
|
||||
done < true_roots.txt
|
||||
wait
|
||||
echo "All done"
|
||||
10
test/batch_recover_resume.sh
Normal file
10
test/batch_recover_resume.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
while read inum rest; do
|
||||
dest="/mnt/recovered/apr29/${inum}"
|
||||
mkdir -p "${dest}"
|
||||
python3 dump_tree.py --skip-existing ${inum} "${dest}/" &
|
||||
while (( $(jobs -r | wc -l) >= 10 )); do
|
||||
wait -n 2>/dev/null || sleep 0.2
|
||||
done
|
||||
done < true_roots.txt
|
||||
wait
|
||||
echo "All done"
|
||||
8
test/batch_restore.sh
Normal file
8
test/batch_restore.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
while read inum rest; do
|
||||
python3 restore_meta.py ${inum} /mnt/recovered/apr29/${inum}/ &
|
||||
while (( $(jobs -r | wc -l) >= 10 )); do
|
||||
wait -n 2>/dev/null || sleep 0.2
|
||||
done
|
||||
done < true_roots.txt
|
||||
wait
|
||||
echo "All done"
|
||||
69
test/bb.sh
Normal file
69
test/bb.sh
Normal file
@@ -0,0 +1,69 @@
|
||||
# MySQL/MariaDB InnoDB pages are 16KB with recognizable structure
|
||||
# Find them directly on the translated device
|
||||
|
||||
python3 -c "
|
||||
CHUNK_B = 128*512
|
||||
LV_START = 5120000*512
|
||||
VIRT_SIZE = 9365766144*512
|
||||
|
||||
def read_virt(offset, length):
|
||||
result = bytearray(length)
|
||||
pos = offset
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK_B)
|
||||
in_group = pos % (5*CHUNK_B)
|
||||
chunk_idx = in_group // CHUNK_B
|
||||
intra = in_group % CHUNK_B
|
||||
seg_len = min(CHUNK_B-intra, remaining)
|
||||
dst_off = pos - offset
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK_B + chunk_idx*CHUNK_B + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
targets = [
|
||||
b'pterodactyl',
|
||||
b'wings_token',
|
||||
b'server_id',
|
||||
b'eula.txt',
|
||||
b'server.properties',
|
||||
b'level.dat',
|
||||
b'bukkit.yml',
|
||||
b'spigot.yml',
|
||||
]
|
||||
|
||||
print('Scanning for game server / pterodactyl data...')
|
||||
chunk = 64*1024*1024
|
||||
offset = 0
|
||||
while offset < VIRT_SIZE:
|
||||
try:
|
||||
data = read_virt(offset, min(chunk, VIRT_SIZE-offset))
|
||||
except:
|
||||
offset += chunk
|
||||
continue
|
||||
|
||||
for target in targets:
|
||||
pos = 0
|
||||
while True:
|
||||
idx = data.find(target, pos)
|
||||
if idx < 0: break
|
||||
abs_byte = offset + idx
|
||||
ctx = data[max(0,idx-80):idx+120]
|
||||
print(f'{target.decode()!r} @ byte {abs_byte} sector {abs_byte//512}')
|
||||
try:
|
||||
print(f' {ctx.decode(\"latin1\",errors=\"replace\")}')
|
||||
except:
|
||||
pass
|
||||
print()
|
||||
pos = idx + 1
|
||||
|
||||
offset += chunk
|
||||
if offset % (10*1024*1024*1024) == 0:
|
||||
print(f'--- {offset//1024**3}GB scanned ---', flush=True)
|
||||
" 2>&1 | tee /tmp/ptero_scan.txt
|
||||
85
test/build_merged.py
Normal file
85
test/build_merged.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
BPG = 32768
|
||||
GDT_ENTRY = 64
|
||||
NUM_GROUPS = 35728
|
||||
|
||||
def is_meta(virt_byte):
|
||||
in_group = virt_byte % (5*CHUNK)
|
||||
return (in_group // CHUNK) == 4
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK)
|
||||
in_group = pos % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
seg_len = min(CHUNK-intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
# Build merged GDT: for each group, use whichever of primary/backup
|
||||
# is NOT in a metadata chunk
|
||||
print('Building merged GDT...')
|
||||
primary_start = BSIZE # block 1
|
||||
backup_start = (1*BPG + 1) * BSIZE # group 1 backup
|
||||
|
||||
# Read both GDTs in full
|
||||
primary_gdt = raw_read(primary_start, NUM_GROUPS * GDT_ENTRY)
|
||||
backup_gdt = raw_read(backup_start, NUM_GROUPS * GDT_ENTRY)
|
||||
|
||||
merged = bytearray(NUM_GROUPS * GDT_ENTRY)
|
||||
primary_used = 0
|
||||
backup_used = 0
|
||||
neither = 0
|
||||
|
||||
for g in range(NUM_GROUPS):
|
||||
prim_byte = primary_start + g * GDT_ENTRY
|
||||
backup_byte = backup_start + g * GDT_ENTRY
|
||||
src_off = g * GDT_ENTRY
|
||||
|
||||
if not is_meta(prim_byte):
|
||||
# Primary is valid - use it
|
||||
merged[src_off:src_off+GDT_ENTRY] = primary_gdt[src_off:src_off+GDT_ENTRY]
|
||||
primary_used += 1
|
||||
elif not is_meta(backup_byte):
|
||||
# Primary is in metadata chunk - use backup
|
||||
merged[src_off:src_off+GDT_ENTRY] = backup_gdt[src_off:src_off+GDT_ENTRY]
|
||||
backup_used += 1
|
||||
else:
|
||||
# Both bad - shouldn't happen given our analysis
|
||||
neither += 1
|
||||
|
||||
print(f'From primary GDT: {primary_used}')
|
||||
print(f'From backup GDT: {backup_used}')
|
||||
print(f'Neither (error): {neither}')
|
||||
assert neither == 0, 'Unexpected gap in coverage!'
|
||||
|
||||
# Verify merged GDT looks sane
|
||||
print()
|
||||
print('Sample entries:')
|
||||
for g in [0, 1, 100, 1000, 32699, 35000, 35727]:
|
||||
e = merged[g*GDT_ENTRY:(g+1)*GDT_ENTRY]
|
||||
bb = struct.unpack_from('<I',e,0)[0]
|
||||
ib = struct.unpack_from('<I',e,4)[0]
|
||||
it = struct.unpack_from('<I',e,8)[0]
|
||||
cs = struct.unpack_from('<H',e,30)[0]
|
||||
print(f' Group {g:6d}: bb={bb:8d} ib={ib:8d} it={it:10d} csum=0x{cs:04x}')
|
||||
|
||||
with open('/tmp/merged_gdt.bin','wb') as f:
|
||||
f.write(merged)
|
||||
print(f'Saved /tmp/merged_gdt.bin ({len(merged)//1024}KB)')
|
||||
133
test/build_tree.py
Normal file
133
test/build_tree.py
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build directory tree from ils output using fls parent pointers.
|
||||
For each directory inode:
|
||||
1. Run fls to get its contents and parent (..)
|
||||
2. Record parent->child relationships
|
||||
3. Walk parent chain to resolve full path
|
||||
4. Place unreachable dirs in /orphans/
|
||||
"""
|
||||
import subprocess, sys, os, collections
|
||||
|
||||
DEVICE = '/dev/nbd0'
|
||||
OUTDIR = '/mnt/recovered'
|
||||
MIN_INODE = 106497 # first intact group
|
||||
|
||||
def fls(inode):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['fls', DEVICE, str(inode)],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
entries = []
|
||||
for line in r.stdout.splitlines():
|
||||
try:
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) < 3: continue
|
||||
type_str = parts[0]
|
||||
ino_str = parts[1].rstrip(':').lstrip('*')
|
||||
name = parts[2].strip()
|
||||
ino = int(ino_str)
|
||||
etype = type_str[0]
|
||||
entries.append((etype, ino, name))
|
||||
except: continue
|
||||
return entries
|
||||
except:
|
||||
return []
|
||||
|
||||
# Load directory inodes from ils output
|
||||
print('Loading directory inodes...')
|
||||
dir_inodes = []
|
||||
with open('/tmp/dir_inodes.txt') as f:
|
||||
for line in f:
|
||||
parts = line.strip().split('|')
|
||||
if len(parts) < 2: continue
|
||||
try:
|
||||
ino = int(parts[0])
|
||||
if ino >= MIN_INODE:
|
||||
dir_inodes.append(ino)
|
||||
except: continue
|
||||
|
||||
print(f'Found {len(dir_inodes)} directory inodes to process')
|
||||
|
||||
# For each directory, get its parent and children
|
||||
# parent_of[inode] = parent_inode
|
||||
# children_of[inode] = [(child_inode, name, type)]
|
||||
parent_of = {}
|
||||
children_of = collections.defaultdict(list)
|
||||
names = {} # inode -> name (as seen from parent)
|
||||
|
||||
print('Running fls on each directory...')
|
||||
for idx, ino in enumerate(dir_inodes):
|
||||
entries = fls(ino)
|
||||
for etype, eino, ename in entries:
|
||||
if ename == '..':
|
||||
parent_of[ino] = eino
|
||||
elif ename == '.':
|
||||
continue
|
||||
else:
|
||||
children_of[ino].append((eino, ename, etype))
|
||||
names[eino] = ename
|
||||
|
||||
if idx % 1000 == 0:
|
||||
print(f' {idx}/{len(dir_inodes)} processed...', flush=True)
|
||||
|
||||
print(f'Built tree: {len(parent_of)} dirs with known parents')
|
||||
|
||||
# Resolve full paths by walking parent chain
|
||||
resolved = {} # inode -> full path
|
||||
|
||||
def resolve(ino, depth=0):
|
||||
if ino in resolved:
|
||||
return resolved[ino]
|
||||
if depth > 50:
|
||||
return None
|
||||
|
||||
parent = parent_of.get(ino)
|
||||
if parent is None:
|
||||
path = f'orphans/{ino}'
|
||||
resolved[ino] = path
|
||||
return path
|
||||
|
||||
# Check if parent is in intact groups
|
||||
if parent < MIN_INODE:
|
||||
# Parent is in zeroed groups — this is a root-level orphan
|
||||
# Use the name if we know it
|
||||
name = names.get(ino, str(ino))
|
||||
path = f'orphans/{name}_{ino}'
|
||||
resolved[ino] = path
|
||||
return path
|
||||
|
||||
parent_path = resolve(parent, depth + 1)
|
||||
if parent_path is None:
|
||||
path = f'orphans/{ino}'
|
||||
else:
|
||||
name = names.get(ino, str(ino))
|
||||
path = os.path.join(parent_path, name)
|
||||
|
||||
resolved[ino] = path
|
||||
return path
|
||||
|
||||
print('Resolving paths...')
|
||||
for ino in dir_inodes:
|
||||
resolve(ino)
|
||||
|
||||
# Print summary
|
||||
orphans = sum(1 for p in resolved.values() if p.startswith('orphans'))
|
||||
resolved_count = len(resolved) - orphans
|
||||
print(f'Resolved paths: {resolved_count}')
|
||||
print(f'Orphaned dirs: {orphans}')
|
||||
print()
|
||||
|
||||
# Show interesting paths
|
||||
print('Sample resolved paths:')
|
||||
for ino, path in sorted(resolved.items(), key=lambda x: x[1]):
|
||||
if any(x in path for x in ['var','pterodactyl','docker','mysql',
|
||||
'www','log','lib']):
|
||||
print(f' inode {ino:10d}: {path}')
|
||||
|
||||
# Save full tree
|
||||
with open('/tmp/dir_tree.txt','w') as f:
|
||||
for ino, path in sorted(resolved.items(), key=lambda x: x[1]):
|
||||
f.write(f'{ino}\t{path}\n')
|
||||
print(f'Saved {len(resolved)} paths to /tmp/dir_tree.txt')
|
||||
71
test/cc.sh
Normal file
71
test/cc.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
python3 -c "
|
||||
CHUNK_B = 128*512
|
||||
LV_START = 5120000*512
|
||||
VIRT_SIZE = 9365766144*512
|
||||
|
||||
def read_virt(offset, length):
|
||||
result = bytearray(length)
|
||||
pos = offset
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK_B)
|
||||
in_group = pos % (5*CHUNK_B)
|
||||
chunk_idx = in_group // CHUNK_B
|
||||
intra = in_group % CHUNK_B
|
||||
seg_len = min(CHUNK_B-intra, remaining)
|
||||
dst_off = pos - offset
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK_B + chunk_idx*CHUNK_B + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
targets = [
|
||||
b'pterodactyl',
|
||||
b'wings_token',
|
||||
b'server.properties',
|
||||
b'level.dat',
|
||||
b'bukkit.yml',
|
||||
b'eula.txt',
|
||||
b'server_id',
|
||||
]
|
||||
|
||||
print('Scanning...')
|
||||
chunk = 64*1024*1024
|
||||
offset = 0
|
||||
hits = {}
|
||||
|
||||
while offset < VIRT_SIZE:
|
||||
try:
|
||||
data = read_virt(offset, min(chunk, VIRT_SIZE-offset))
|
||||
except:
|
||||
offset += chunk
|
||||
continue
|
||||
|
||||
for target in targets:
|
||||
pos = 0
|
||||
while True:
|
||||
idx = data.find(target, pos)
|
||||
if idx < 0: break
|
||||
abs_byte = offset + idx
|
||||
t = target.decode()
|
||||
if t not in hits:
|
||||
hits[t] = []
|
||||
hits[t].append(abs_byte)
|
||||
pos = idx + 1
|
||||
|
||||
offset += chunk
|
||||
if offset % (10*1024*1024*1024) == 0:
|
||||
print(f'--- {offset//1024**3}GB scanned ---', flush=True)
|
||||
|
||||
print()
|
||||
print('=== RESULTS ===')
|
||||
for target, locations in hits.items():
|
||||
print(f'{target}: {len(locations)} hits')
|
||||
for loc in locations[:5]:
|
||||
print(f' byte {loc} sector {loc//512}')
|
||||
" 2>&1 | tee /tmp/ptero_scan.txt
|
||||
20
test/check_deleted_orphans.py
Normal file
20
test/check_deleted_orphans.py
Normal file
@@ -0,0 +1,20 @@
|
||||
print(f"\nOrphaned roots: {len(true_roots)}")
|
||||
print(f"{'inode':>12} {'parent':>12} {'status':>12} {'dtime':>12} reason")
|
||||
print('-' * 75)
|
||||
|
||||
with open(DEV, 'rb') as f:
|
||||
for inum, parent, reason in sorted(true_roots):
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
status = classify_inode(idata, slot)
|
||||
dtime = struct.unpack_from('<I', idata, slot + 20)[0]
|
||||
# Format dtime as human readable if set
|
||||
if dtime:
|
||||
import datetime
|
||||
dt = datetime.datetime.fromtimestamp(dtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
dt = 'never'
|
||||
except Exception:
|
||||
status, dt = 'unreadable', 'unknown'
|
||||
|
||||
print(f"{inum:>12} {parent:>12} {status:>12} {dt:>19} {reason}")
|
||||
46
test/dump_tree.py
Normal file
46
test/dump_tree.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recursive ext4 directory dumper by inode number.
|
||||
Bypasses all metadata validation - uses extent trees directly.
|
||||
"""
|
||||
import struct, os, sys, stat
|
||||
from pathlib import Path
|
||||
|
||||
DEV = '/dev/dm-0'
|
||||
BLOCK = 4096
|
||||
BACKUP_SB_BLOCK = 32768
|
||||
|
||||
import ext4lib
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
import argparse
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Recover ext4 directory tree by inode')
|
||||
parser.add_argument('inode', type=int, help='Root inode number')
|
||||
parser.add_argument('dest', help='Destination directory')
|
||||
parser.add_argument('--skip-existing', action='store_true',
|
||||
help='Skip recovery if destination directory already exists and is non-empty')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.skip_existing and os.path.isdir(args.dest) and os.listdir(args.dest):
|
||||
print(f"Skipping inode {args.inode} -> {args.dest} (already exists)")
|
||||
sys.exit(0)
|
||||
|
||||
with open(DEV, 'rb') as f:
|
||||
sb_data = ext4lib.read_at(f, BACKUP_SB_BLOCK * BLOCK, 1024)
|
||||
sb = ext4lib.parse_superblock(sb_data)
|
||||
assert sb['magic'] == 0xef53
|
||||
|
||||
num_groups = (sb['blocks_count'] + sb['blocks_per_group'] - 1) \
|
||||
// sb['blocks_per_group']
|
||||
gdt_data = ext4lib.read_at(f, (BACKUP_SB_BLOCK + 1) * BLOCK,
|
||||
num_groups * sb['desc_size'])
|
||||
|
||||
print(f"Dumping inode {args.inode} -> {args.dest}")
|
||||
ext4lib.dump_tree(f, sb, gdt_data, args.inode, args.dest)
|
||||
print(f"Done inode {args.inode}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
270
test/ext4lib.py
Normal file
270
test/ext4lib.py
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
EXT4 Filesystem Libraries
|
||||
"""
|
||||
import struct, os, sys, stat
|
||||
from pathlib import Path
|
||||
|
||||
BLOCK=4096
|
||||
|
||||
def read_at(f, offset, size):
|
||||
f.seek(offset)
|
||||
return f.read(size)
|
||||
|
||||
def parse_superblock(data):
|
||||
sb = {}
|
||||
sb['inodes_count'] = struct.unpack_from('<I', data, 0)[0]
|
||||
sb['blocks_count'] = struct.unpack_from('<I', data, 4)[0]
|
||||
sb['blocks_per_group'] = struct.unpack_from('<I', data, 32)[0]
|
||||
sb['inodes_per_group'] = struct.unpack_from('<I', data, 40)[0]
|
||||
sb['inode_size'] = struct.unpack_from('<H', data, 88)[0]
|
||||
sb['magic'] = struct.unpack_from('<H', data, 56)[0]
|
||||
sb['desc_size'] = struct.unpack_from('<H', data, 254)[0] or 32
|
||||
return sb
|
||||
|
||||
def parse_gdt_entry(gdt_data, offset, desc_size):
|
||||
lo = struct.unpack_from('<I', gdt_data, offset + 8)[0]
|
||||
if desc_size >= 64:
|
||||
hi = struct.unpack_from('<I', gdt_data, offset + 40)[0]
|
||||
return lo | (hi << 32)
|
||||
return lo
|
||||
|
||||
def parse_extent_tree(data, inode_offset):
|
||||
base = inode_offset + 40
|
||||
magic, entries, _, depth = struct.unpack_from('<HHHH', data, base)
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
extents = []
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
o = base + 12 + i * 12
|
||||
if o + 12 > len(data): break
|
||||
l_block = struct.unpack_from('<I', data, o )[0]
|
||||
ee_len = struct.unpack_from('<H', data, o + 4)[0]
|
||||
start_hi = struct.unpack_from('<H', data, o + 6)[0]
|
||||
start_lo = struct.unpack_from('<I', data, o + 8)[0]
|
||||
phys = (start_hi << 32) | start_lo
|
||||
if phys > 0:
|
||||
extents.append((l_block, phys, ee_len & 0x7FFF))
|
||||
else:
|
||||
# Depth > 0: extent index node - follow first child
|
||||
# (handles large dirs gracefully)
|
||||
o = base + 12
|
||||
ei_leaf_lo = struct.unpack_from('<I', data, o + 4)[0]
|
||||
ei_leaf_hi = struct.unpack_from('<H', data, o + 8)[0]
|
||||
extents.append((0, (ei_leaf_hi << 32) | ei_leaf_lo, 1))
|
||||
return extents
|
||||
|
||||
def read_extent_tree_blocks(f, data, inode_offset):
|
||||
"""
|
||||
Fully recursive extent tree walker.
|
||||
Returns sorted list of (logical_block, phys_block) pairs.
|
||||
"""
|
||||
base = inode_offset + 40
|
||||
magic, entries, _, depth = struct.unpack_from('<HHHH', data, base)
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
return _walk_extent_node(f, data, base, depth)
|
||||
|
||||
def _walk_extent_node(f, data, base, depth):
|
||||
magic, entries, _, _ = struct.unpack_from('<HHHH', data, base)
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
|
||||
result = []
|
||||
if depth == 0:
|
||||
# Leaf node - actual extents
|
||||
for i in range(entries):
|
||||
o = base + 12 + i * 12
|
||||
l_block = struct.unpack_from('<I', data, o )[0]
|
||||
ee_len = struct.unpack_from('<H', data, o + 4)[0]
|
||||
start_hi = struct.unpack_from('<H', data, o + 6)[0]
|
||||
start_lo = struct.unpack_from('<I', data, o + 8)[0]
|
||||
phys = (start_hi << 32) | start_lo
|
||||
if phys > 0:
|
||||
for b in range(ee_len & 0x7FFF):
|
||||
result.append((l_block + b, phys + b))
|
||||
else:
|
||||
# Index node - recurse into each child
|
||||
for i in range(entries):
|
||||
o = base + 12 + i * 12
|
||||
leaf_lo = struct.unpack_from('<I', data, o + 4)[0]
|
||||
leaf_hi = struct.unpack_from('<H', data, o + 8)[0]
|
||||
leaf_block = (leaf_hi << 32) | leaf_lo
|
||||
try:
|
||||
child_data = read_at(f, leaf_block * BLOCK, BLOCK)
|
||||
result.extend(_walk_extent_node(f, child_data, 0, depth - 1))
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
def read_inode(f, sb, gdt_data, inum):
|
||||
"""Return raw inode block data and offset within it."""
|
||||
grp = (inum - 1) // sb['inodes_per_group']
|
||||
local_idx = (inum - 1) % sb['inodes_per_group']
|
||||
tbl_block = parse_gdt_entry(gdt_data, grp * sb['desc_size'], sb['desc_size'])
|
||||
byte_off = local_idx * sb['inode_size']
|
||||
blk_off = byte_off // BLOCK
|
||||
slot = byte_off % BLOCK
|
||||
data = read_at(f, (tbl_block + blk_off) * BLOCK, BLOCK)
|
||||
return data, slot
|
||||
|
||||
def classify_inode(idata, slot):
|
||||
"""
|
||||
Returns 'deleted', 'orphaned', or 'active' based on inode fields.
|
||||
"""
|
||||
mode = struct.unpack_from('<H', idata, slot + 0)[0]
|
||||
links_count = struct.unpack_from('<H', idata, slot + 26)[0]
|
||||
dtime = struct.unpack_from('<I', idata, slot + 20)[0]
|
||||
flags = struct.unpack_from('<I', idata, slot + 32)[0]
|
||||
|
||||
if dtime != 0 and links_count == 0:
|
||||
return 'deleted'
|
||||
if dtime != 0 and links_count > 0:
|
||||
# Inconsistent - probably corruption
|
||||
return 'corrupt'
|
||||
if dtime == 0 and links_count == 0:
|
||||
# Unallocated inode - should not appear in dir entries
|
||||
return 'unallocated'
|
||||
return 'active' # dtime=0, links_count>0 - normal live inode
|
||||
|
||||
def read_dir_entries(f, sb, gdt_data, inum):
|
||||
"""Return dict of name -> (child_inum, ftype)."""
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
entries = {}
|
||||
for logical, phys in sorted(read_extent_tree_blocks(f, idata, slot)):
|
||||
try:
|
||||
bdata = read_at(f, phys * BLOCK, BLOCK)
|
||||
offset = 0
|
||||
while offset < BLOCK - 8:
|
||||
e_ino, rec_len, name_len, ftype = \
|
||||
struct.unpack_from('<IHBB', bdata, offset)
|
||||
if rec_len < 8 or offset + rec_len > BLOCK:
|
||||
break
|
||||
if e_ino != 0 and name_len > 0:
|
||||
name = bdata[offset+8:offset+8+name_len]\
|
||||
.decode('utf-8', errors='replace')
|
||||
entries[name] = (e_ino, ftype)
|
||||
offset += rec_len
|
||||
except OSError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def dump_file(f, sb, gdt_data, inum, dest_path):
|
||||
"""Extract a regular file by inode to dest_path."""
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
size_lo = struct.unpack_from('<I', idata, slot + 4)[0]
|
||||
size_hi = struct.unpack_from('<I', idata, slot + 108)[0]
|
||||
size = size_lo | (size_hi << 32)
|
||||
flags = struct.unpack_from('<I', idata, slot + 32)[0]
|
||||
|
||||
if flags & 0x10000000:
|
||||
# Inline data - stored in inode body after extent header
|
||||
inline = idata[slot+40:slot+40+size]
|
||||
with open(dest_path, 'wb') as out:
|
||||
out.write(inline)
|
||||
return True
|
||||
|
||||
blocks = sorted(read_extent_tree_blocks(f, idata, slot))
|
||||
written = 0
|
||||
with open(dest_path, 'wb') as out:
|
||||
# Handle sparse files - fill holes with zeros
|
||||
for logical, phys in blocks:
|
||||
hole = logical * BLOCK
|
||||
if hole > written:
|
||||
out.seek(hole)
|
||||
written = hole
|
||||
remaining = size - written
|
||||
if remaining <= 0:
|
||||
break
|
||||
chunk = read_at(f, phys * BLOCK, BLOCK)
|
||||
out.write(chunk[:min(BLOCK, remaining)])
|
||||
written += min(BLOCK, remaining)
|
||||
out.truncate(size)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def dump_symlink(f, sb, gdt_data, inum, dest_path):
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
size = struct.unpack_from('<I', idata, slot + 4)[0]
|
||||
if size <= 60:
|
||||
target = idata[slot+40:slot+40+size].decode('utf-8', errors='replace')
|
||||
else:
|
||||
extents = read_extent_tree_blocks(f, idata, slot)
|
||||
if not extents:
|
||||
return False
|
||||
bdata = read_at(f, extents[0][1] * BLOCK, BLOCK)
|
||||
target = bdata[:size].decode('utf-8', errors='replace')
|
||||
|
||||
# Strip null terminator, control characters, and anything after first null
|
||||
target = target.split('\x00')[0].strip()
|
||||
|
||||
if not target:
|
||||
print(f" WARN empty symlink target for {dest_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Validate target looks like a path
|
||||
if any(ord(c) < 32 for c in target):
|
||||
print(f" WARN control chars in symlink target {dest_path!r} -> {target!r}",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
|
||||
if os.path.lexists(dest_path):
|
||||
return True # already exists from a previous run
|
||||
|
||||
os.symlink(target, dest_path)
|
||||
return True
|
||||
except (OSError, IndexError) as e:
|
||||
print(f" WARN symlink {dest_path}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# ── recursive dumper ─────────────────────────────────────────────────────────
|
||||
|
||||
FTYPE_REG = 1
|
||||
FTYPE_DIR = 2
|
||||
FTYPE_SYM = 7
|
||||
|
||||
def dump_tree(f, sb, gdt_data, inum, dest_dir, depth=0, visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if inum in visited:
|
||||
return
|
||||
visited.add(inum)
|
||||
|
||||
try:
|
||||
entries = read_dir_entries(f, sb, gdt_data, inum)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
for name, (child_inum, ftype) in entries.items():
|
||||
if name in ('.', '..'):
|
||||
continue
|
||||
safe_name = name.replace('/', '_').replace('\x00', '')
|
||||
dest = os.path.join(dest_dir, safe_name)
|
||||
|
||||
try:
|
||||
# If ftype unknown, derive from inode mode
|
||||
if ftype == 0:
|
||||
idata, slot = read_inode(f, sb, gdt_data, child_inum)
|
||||
mode = struct.unpack_from('<H', idata, slot)[0]
|
||||
itype = mode & 0xF000
|
||||
if itype == 0x4000: ftype = FTYPE_DIR
|
||||
elif itype == 0x8000: ftype = FTYPE_REG
|
||||
elif itype == 0xA000: ftype = FTYPE_SYM
|
||||
|
||||
if ftype == FTYPE_DIR:
|
||||
dump_tree(f, sb, gdt_data, child_inum, dest,
|
||||
depth+1, visited)
|
||||
elif ftype == FTYPE_REG:
|
||||
dump_file(f, sb, gdt_data, child_inum, dest)
|
||||
elif ftype == FTYPE_SYM:
|
||||
dump_symlink(f, sb, gdt_data, child_inum, dest)
|
||||
# ftype still 0 after mode check = special file, skip
|
||||
|
||||
except Exception as e:
|
||||
print(f" WARN: {dest}: {e}", file=sys.stderr)
|
||||
35
test/ff.sh
Normal file
35
test/ff.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
# Search for /var related strings in the assembled array
|
||||
# Things that would only appear in /var:
|
||||
python3 -c "
|
||||
import os
|
||||
|
||||
targets = [
|
||||
b'/var/log/syslog',
|
||||
b'/var/lib/apt',
|
||||
b'/var/cache',
|
||||
b'dpkg/status',
|
||||
b'apt/lists',
|
||||
b'journald',
|
||||
b'/var/log/auth.log',
|
||||
]
|
||||
|
||||
with open('/dev/nbd0', 'rb') as f:
|
||||
chunk = 128*1024*1024
|
||||
offset = 0
|
||||
limit = 50*1024*1024*1024
|
||||
while offset < limit:
|
||||
f.seek(offset)
|
||||
data = f.read(chunk)
|
||||
if not data: break
|
||||
for target in targets:
|
||||
pos = data.find(target)
|
||||
if pos >= 0:
|
||||
abs_byte = offset + pos
|
||||
ctx = data[max(0,pos-50):pos+100]
|
||||
print(f'{target.decode()!r} at byte {abs_byte}')
|
||||
print(f' {ctx.decode(\"latin1\",errors=\"replace\")}')
|
||||
print()
|
||||
offset += chunk
|
||||
if offset % (1024*1024*1024) == 0:
|
||||
print(f'Scanned {offset//1024//1024//1024}GB...',flush=True)
|
||||
" 2>&1 | grep -v "^Scanned"
|
||||
69
test/find_parent.py
Normal file
69
test/find_parent.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import subprocess
|
||||
|
||||
DEVICE = '/dev/nbd0'
|
||||
|
||||
def fls(inode):
|
||||
try:
|
||||
r = subprocess.run(['fls', DEVICE, str(inode)],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return r.stdout
|
||||
except:
|
||||
return ''
|
||||
|
||||
def get_parent_and_name(inode):
|
||||
output = fls(inode)
|
||||
parent = None
|
||||
children = []
|
||||
for line in output.splitlines():
|
||||
try:
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) < 3: continue
|
||||
type_str = parts[0]
|
||||
ino = int(parts[1].rstrip(':').lstrip('*'))
|
||||
name = parts[2].strip()
|
||||
if name == '..':
|
||||
parent = ino
|
||||
elif name != '.':
|
||||
children.append((type_str[0], ino, name))
|
||||
except: continue
|
||||
return parent, children
|
||||
|
||||
# Walk up from pterodactyl
|
||||
print('Walking up from pterodactyl (inode 1574102)...')
|
||||
chain = [(1574102, 'pterodactyl')]
|
||||
inode = 1574102
|
||||
for _ in range(20):
|
||||
parent, _ = get_parent_and_name(inode)
|
||||
if parent is None or parent == inode:
|
||||
break
|
||||
grp = (parent-1)//8192
|
||||
# Get parent's name by looking at its own .. entry
|
||||
parent_parent, parent_children = get_parent_and_name(parent)
|
||||
# Find our name in parent's listing
|
||||
name = f'inode_{parent}'
|
||||
for t,i,n in parent_children:
|
||||
if i == inode:
|
||||
name = n
|
||||
break
|
||||
status = 'INTACT' if grp >= 13 else 'LOST'
|
||||
print(f' [{status}] inode {parent} = {name!r} (group {grp})')
|
||||
chain.append((parent, name))
|
||||
if grp < 13:
|
||||
print(f' Reached zeroed region at inode {parent} - stopping')
|
||||
break
|
||||
inode = parent
|
||||
|
||||
print()
|
||||
print('Chain (bottom to top):')
|
||||
for ino, name in chain:
|
||||
print(f' {name} (inode {ino})')
|
||||
|
||||
# The highest intact inode is our extraction root
|
||||
top_inode, top_name = chain[-1]
|
||||
grp = (top_inode-1)//8192
|
||||
if grp >= 13:
|
||||
print(f'\nExtraction root: inode {top_inode} ({top_name!r})')
|
||||
else:
|
||||
# Use second to last
|
||||
top_inode, top_name = chain[-2]
|
||||
print(f'\nExtraction root: inode {top_inode} ({top_name!r})')
|
||||
6415
test/fls_dirs.txt
Normal file
6415
test/fls_dirs.txt
Normal file
File diff suppressed because it is too large
Load Diff
41
test/gg.sh
Normal file
41
test/gg.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
python3 -c "
|
||||
targets = [
|
||||
b'pterodactyl',
|
||||
b'/var/lib/pterodactyl',
|
||||
b'server.properties', # Minecraft
|
||||
b'level.dat', # Minecraft world
|
||||
b'bukkit.yml',
|
||||
b'spigot.yml',
|
||||
b'paper.yml',
|
||||
b'eula.txt',
|
||||
b'/var/lib/docker',
|
||||
b'wings',
|
||||
b'daemon.json',
|
||||
]
|
||||
|
||||
with open('/dev/nbd0', 'rb') as f:
|
||||
chunk = 128*1024*1024
|
||||
offset = 0
|
||||
limit = 100*1024*1024*1024 # first 100GB
|
||||
found = {}
|
||||
while offset < limit:
|
||||
f.seek(offset)
|
||||
data = f.read(chunk)
|
||||
if not data: break
|
||||
for target in targets:
|
||||
pos = 0
|
||||
while True:
|
||||
idx = data.find(target, pos)
|
||||
if idx < 0: break
|
||||
abs_byte = offset + idx
|
||||
if target not in found:
|
||||
found[target] = []
|
||||
found[target].append(abs_byte)
|
||||
pos = idx + 1
|
||||
offset += chunk
|
||||
if offset % (2*1024*1024*1024) == 0:
|
||||
print(f'Scanned {offset//1024//1024//1024}GB...',flush=True)
|
||||
|
||||
for t,locs in found.items():
|
||||
print(f'{t.decode(\"latin1\")}: {len(locs)} hits, first at byte {locs[0]}')
|
||||
" 2>&1
|
||||
54
test/hh.sh
Normal file
54
test/hh.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
python3 -c "
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
VIRT_SIZE = 9372172288*512
|
||||
|
||||
def read_virt(offset, length):
|
||||
result = bytearray(length)
|
||||
pos = offset
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK)
|
||||
in_group = pos % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
seg_len = min(CHUNK-intra, remaining)
|
||||
dst_off = pos - offset
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
# Check last 8MB of virtual disk
|
||||
last_8mb_start = VIRT_SIZE - 8*1024*1024
|
||||
print(f'Checking last 8MB: virtual bytes {last_8mb_start} to {VIRT_SIZE}')
|
||||
print(f'= virtual sectors {last_8mb_start//512} to {VIRT_SIZE//512}')
|
||||
|
||||
data = read_virt(last_8mb_start, 8*1024*1024)
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
zero_runs = 0
|
||||
in_zero = False
|
||||
for b in data:
|
||||
if b == 0 and not in_zero:
|
||||
in_zero = True
|
||||
zero_runs += 1
|
||||
elif b != 0:
|
||||
in_zero = False
|
||||
|
||||
print(f'Non-zero bytes: {nonzero} / {8*1024*1024}')
|
||||
print(f'Zero runs: {zero_runs}')
|
||||
print(f'First 32 bytes: {data[:32].hex()}')
|
||||
print(f'Last 32 bytes: {data[-32:].hex()}')
|
||||
|
||||
# Find first non-zero from the end
|
||||
for i in range(len(data)-1, -1, -1):
|
||||
if data[i] != 0:
|
||||
print(f'Last non-zero byte at offset {i} from last_8mb_start')
|
||||
print(f'= virtual byte {last_8mb_start+i}')
|
||||
break
|
||||
"
|
||||
266
test/inode.list.txt
Normal file
266
test/inode.list.txt
Normal file
@@ -0,0 +1,266 @@
|
||||
Device: /dev/nbd0
|
||||
Scanning groups 13 to 35727...
|
||||
|
||||
Group 1000/35728...
|
||||
Group 2000/35728...
|
||||
Group 3000/35728...
|
||||
Group 4000/35728...
|
||||
Group 5000/35728...
|
||||
Group 6000/35728...
|
||||
Group 7000/35728...
|
||||
Group 8000/35728...
|
||||
Group 9000/35728...
|
||||
Group 10000/35728...
|
||||
[INTACT] 'mysql' child= 1315529 parent= 83874094 type=dir
|
||||
Group 11000/35728...
|
||||
[INTACT] 'apache2' child= 1441863 parent= 92258147 type=dir
|
||||
Group 12000/35728...
|
||||
[INTACT] 'apache2' child= 1572931 parent= 100646751 type=dir
|
||||
[INTACT] 'pterodactyl' child= 1574102 parent= 100647925 type=dir
|
||||
[INTACT] 'log' child= 4473829 parent= 100648635 type=dir
|
||||
[INTACT] 'log' child= 1590880 parent= 100664187 type=dir
|
||||
[INTACT] 'log' child= 1590928 parent= 100664751 type=dir
|
||||
[INTACT] 'archives' child= 1590964 parent= 100664787 type=dir
|
||||
[INTACT] 'mysql' child= 1591190 parent= 100664840 type=dir
|
||||
[INTACT] 'mysql' child= 1594026 parent= 100666127 type=link
|
||||
[INTACT] 'mysql' child= 1593996 parent= 100667804 type=dir
|
||||
[INTACT] 'log' child= 1703998 parent= 100678162 type=dir
|
||||
Group 13000/35728...
|
||||
[INTACT] 'docker' child= 1704222 parent= 109035581 type=file
|
||||
[INTACT] 'log' child= 1708761 parent= 109039668 type=dir
|
||||
[INTACT] 'log' child= 1713577 parent= 109044468 type=dir
|
||||
[INTACT] 'archives' child= 1713111 parent= 109044470 type=dir
|
||||
Group 14000/35728...
|
||||
Group 15000/35728...
|
||||
[INTACT] 'archives' child= 1976627 parent= 125823058 type=dir
|
||||
[INTACT] 'mysql' child= 1976852 parent= 125823110 type=dir
|
||||
[INTACT] 'mysql' child= 1979688 parent= 125824397 type=link
|
||||
[INTACT] 'log' child= 1991889 parent= 125836432 type=dir
|
||||
Group 16000/35728...
|
||||
[INTACT] 'log' child= 2097588 parent= 134201370 type=dir
|
||||
Group 17000/35728...
|
||||
Group 18000/35728...
|
||||
[INTACT] 'log' child= 2371446 parent= 150990428 type=dir
|
||||
[INTACT] 'archives' child= 2371391 parent= 150990430 type=dir
|
||||
[INTACT] 'log' child= 2372407 parent= 150991423 type=dir
|
||||
[INTACT] 'mysql' child= 2385299 parent= 151004317 type=dir
|
||||
Group 19000/35728...
|
||||
[INTACT] 'log' child= 2498053 parent= 159374142 type=dir
|
||||
[INTACT] 'log' child= 2498610 parent= 159375135 type=dir
|
||||
[INTACT] 'archives' child= 2498562 parent= 159375137 type=dir
|
||||
[INTACT] 'log' child= 2502619 parent= 159379170 type=dir
|
||||
[INTACT] 'archives' child= 2502597 parent= 159379172 type=dir
|
||||
[INTACT] 'pterodactyl' child= 2502697 parent= 159379272 type=dir
|
||||
[INTACT] 'pterodactyl' child= 2502709 parent= 159379273 type=link
|
||||
Group 20000/35728...
|
||||
Group 21000/35728...
|
||||
[INTACT] 'mysql' child= 2754290 parent= 176145895 type=dir
|
||||
Group 22000/35728...
|
||||
Group 23000/35728...
|
||||
[INTACT] 'log' child= 3026191 parent= 192932796 type=dir
|
||||
[INTACT] 'archives' child= 3026079 parent= 192932798 type=dir
|
||||
[INTACT] 'log' child= 3026239 parent= 192932938 type=dir
|
||||
[INTACT] 'docker' child= 3026262 parent= 192932981 type=file
|
||||
[INTACT] 'log' child= 3032931 parent= 192939639 type=dir
|
||||
Group 24000/35728...
|
||||
[INTACT] 'log' child= 3149355 parent= 201313102 type=dir
|
||||
[INTACT] 'archives' child= 3148850 parent= 201313105 type=dir
|
||||
[INTACT] 'log' child= 3151959 parent= 201315933 type=dir
|
||||
[INTACT] 'log' child= 3156862 parent= 201320877 type=dir
|
||||
[INTACT] 'log' child= 3160172 parent= 201324390 type=dir
|
||||
[INTACT] 'archives' child= 3160137 parent= 201324392 type=dir
|
||||
[INTACT] 'log' child= 3164477 parent= 201328491 type=dir
|
||||
[INTACT] 'mysql' child= 3164741 parent= 201328825 type=dir
|
||||
Group 25000/35728...
|
||||
[INTACT] 'mysql' child= 3280643 parent= 209702403 type=dir
|
||||
[INTACT] 'mysql' child= 3281131 parent= 209702916 type=dir
|
||||
[INTACT] 'mysql' child= 9046451 parent= 210222501 type=dir
|
||||
[INTACT] 'log' child= 9044379 parent= 210223241 type=dir
|
||||
[INTACT] 'log' child= 15349367 parent= 210324339 type=dir
|
||||
[INTACT] 'log' child= 12204268 parent= 210329845 type=dir
|
||||
[INTACT] 'log' child= 14955773 parent= 210360481 type=dir
|
||||
[INTACT] 'archives' child= 14953284 parent= 210360483 type=dir
|
||||
[INTACT] 'mysql' child= 4458148 parent= 210467478 type=dir
|
||||
[INTACT] 'pterodactyl' child= 4459214 parent= 210493665 type=dir
|
||||
[INTACT] 'docker' child= 4459256 parent= 210493665 type=dir
|
||||
[INTACT] 'mysql' child= 4458283 parent= 210493665 type=dir
|
||||
[INTACT] 'apache2' child= 4458419 parent= 210493665 type=dir
|
||||
[INTACT] 'mysql' child= 49414148 parent= 210523963 type=dir
|
||||
[INTACT] 'log' child= 19936354 parent= 210594543 type=dir
|
||||
[INTACT] 'archives' child= 15368106 parent= 210596329 type=dir
|
||||
[INTACT] 'archives' child= 16001027 parent= 210609986 type=dir
|
||||
[INTACT] 'log' child= 15082000 parent= 210615907 type=dir
|
||||
[INTACT] 'log' child= 17055930 parent= 210685762 type=dir
|
||||
[INTACT] 'archives' child= 14821978 parent= 210685769 type=dir
|
||||
[INTACT] 'apache2' child= 15732946 parent= 210686638 type=dir
|
||||
[INTACT] 'archives' child= 14967386 parent= 210699927 type=dir
|
||||
[INTACT] 'mysql' child= 13373267 parent= 212052530 type=dir
|
||||
Group 26000/35728...
|
||||
[INTACT] 'docker' child= 3410441 parent= 218089768 type=file
|
||||
[INTACT] 'log' child= 3415134 parent= 218093944 type=dir
|
||||
[INTACT] 'log' child= 3415175 parent= 218094502 type=dir
|
||||
[INTACT] 'log' child= 3430467 parent= 218107906 type=dir
|
||||
Group 27000/35728...
|
||||
[INTACT] 'log' child= 3539730 parent= 226476593 type=dir
|
||||
[INTACT] 'log' child= 3541853 parent= 226477131 type=dir
|
||||
[INTACT] 'archives' child= 3540270 parent= 226477133 type=dir
|
||||
[INTACT] 'log' child= 3540439 parent= 226477266 type=dir
|
||||
[INTACT] 'archives' child= 3540405 parent= 226477268 type=dir
|
||||
[INTACT] 'log' child= 3542407 parent= 226479225 type=dir
|
||||
[INTACT] 'archives' child= 3542364 parent= 226479227 type=dir
|
||||
[INTACT] 'log' child= 3543789 parent= 226480565 type=dir
|
||||
[INTACT] 'mysql' child= 3544957 parent= 226481649 type=dir
|
||||
Group 28000/35728...
|
||||
[INTACT] 'log' child= 3671475 parent= 234865777 type=dir
|
||||
[INTACT] 'archives' child= 3671380 parent= 234865779 type=dir
|
||||
[INTACT] 'log' child= 3672438 parent= 234866814 type=dir
|
||||
[INTACT] 'archives' child= 3672417 parent= 234866816 type=dir
|
||||
Group 29000/35728...
|
||||
[INTACT] 'mysql' child= 3802782 parent= 243254702 type=dir
|
||||
[INTACT] 'log' child= 3822192 parent= 243271285 type=dir
|
||||
Group 30000/35728...
|
||||
[INTACT] 'log' child= 3942248 parent= 251649384 type=dir
|
||||
[INTACT] 'mysql' child= 3946290 parent= 251655740 type=dir
|
||||
[INTACT] 'docker' child= 3947763 parent= 251657234 type=file
|
||||
[INTACT] 'log' child= 3952497 parent= 251661968 type=dir
|
||||
[INTACT] 'mysql' child= 3955138 parent= 251664296 type=dir
|
||||
[INTACT] 'mysql' child= 3961890 parent= 251668087 type=link
|
||||
[INTACT] 'mysql' child= 3961819 parent= 251671257 type=dir
|
||||
[INTACT] 'log' child= 3964640 parent= 251673996 type=dir
|
||||
[INTACT] 'archives' child= 3964527 parent= 251673998 type=dir
|
||||
Group 31000/35728...
|
||||
[INTACT] 'archives' child= 4063735 parent= 260030742 type=dir
|
||||
Group 32000/35728...
|
||||
[INTACT] 'mysql' child= 4194328 parent= 268418850 type=dir
|
||||
Group 33000/35728...
|
||||
[INTACT] 'log' child= 4335460 parent= 276815206 type=dir
|
||||
[INTACT] 'mysql' child= 4339502 parent= 276821560 type=dir
|
||||
[INTACT] 'docker' child= 4341215 parent= 276823294 type=file
|
||||
[INTACT] 'log' child= 4345359 parent= 276826952 type=dir
|
||||
[INTACT] 'log' child= 4345911 parent= 276827945 type=dir
|
||||
[INTACT] 'archives' child= 4345868 parent= 276827947 type=dir
|
||||
[INTACT] 'log' child= 4347291 parent= 276829282 type=dir
|
||||
[INTACT] 'log' child= 4348236 parent= 276830303 type=dir
|
||||
Group 34000/35728...
|
||||
[INTACT] 'apache2' child= 4458777 parent= 285198295 type=dir
|
||||
[INTACT] 'apache2' child= 4459043 parent= 285198521 type=dir
|
||||
[INTACT] 'log' child= 4468198 parent= 285205428 type=dir
|
||||
[INTACT] 'mysql' child= 4472275 parent= 285211867 type=dir
|
||||
[INTACT] 'docker' child= 4485280 parent= 285224895 type=file
|
||||
[INTACT] 'log' child= 4587550 parent= 285229071 type=dir
|
||||
Group 35000/35728...
|
||||
|
||||
=== SUMMARY ===
|
||||
[INTACT] 'apache2' child=1441863 parent=92258147 type=dir
|
||||
[INTACT] 'apache2' child=1572931 parent=100646751 type=dir
|
||||
[INTACT] 'apache2' child=4458419 parent=210493665 type=dir
|
||||
[INTACT] 'apache2' child=4458777 parent=285198295 type=dir
|
||||
[INTACT] 'apache2' child=4459043 parent=285198521 type=dir
|
||||
[INTACT] 'apache2' child=15732946 parent=210686638 type=dir
|
||||
[INTACT] 'archives' child=1590964 parent=100664787 type=dir
|
||||
[INTACT] 'archives' child=1713111 parent=109044470 type=dir
|
||||
[INTACT] 'archives' child=1976627 parent=125823058 type=dir
|
||||
[INTACT] 'archives' child=2371391 parent=150990430 type=dir
|
||||
[INTACT] 'archives' child=2498562 parent=159375137 type=dir
|
||||
[INTACT] 'archives' child=2502597 parent=159379172 type=dir
|
||||
[INTACT] 'archives' child=3026079 parent=192932798 type=dir
|
||||
[INTACT] 'archives' child=3148850 parent=201313105 type=dir
|
||||
[INTACT] 'archives' child=3160137 parent=201324392 type=dir
|
||||
[INTACT] 'archives' child=3540270 parent=226477133 type=dir
|
||||
[INTACT] 'archives' child=3540405 parent=226477268 type=dir
|
||||
[INTACT] 'archives' child=3542364 parent=226479227 type=dir
|
||||
[INTACT] 'archives' child=3671380 parent=234865779 type=dir
|
||||
[INTACT] 'archives' child=3672417 parent=234866816 type=dir
|
||||
[INTACT] 'archives' child=3964527 parent=251673998 type=dir
|
||||
[INTACT] 'archives' child=4063735 parent=260030742 type=dir
|
||||
[INTACT] 'archives' child=4345868 parent=276827947 type=dir
|
||||
[INTACT] 'archives' child=14821978 parent=210685769 type=dir
|
||||
[INTACT] 'archives' child=14953284 parent=210360483 type=dir
|
||||
[INTACT] 'archives' child=14967386 parent=210699927 type=dir
|
||||
[INTACT] 'archives' child=15368106 parent=210596329 type=dir
|
||||
[INTACT] 'archives' child=16001027 parent=210609986 type=dir
|
||||
[INTACT] 'docker' child=1704222 parent=109035581 type=file
|
||||
[INTACT] 'docker' child=3026262 parent=192932981 type=file
|
||||
[INTACT] 'docker' child=3410441 parent=218089768 type=file
|
||||
[INTACT] 'docker' child=3947763 parent=251657234 type=file
|
||||
[INTACT] 'docker' child=4341215 parent=276823294 type=file
|
||||
[INTACT] 'docker' child=4459256 parent=210493665 type=dir
|
||||
[INTACT] 'docker' child=4485280 parent=285224895 type=file
|
||||
[INTACT] 'log' child=1590880 parent=100664187 type=dir
|
||||
[INTACT] 'log' child=1590928 parent=100664751 type=dir
|
||||
[INTACT] 'log' child=1703998 parent=100678162 type=dir
|
||||
[INTACT] 'log' child=1708761 parent=109039668 type=dir
|
||||
[INTACT] 'log' child=1713577 parent=109044468 type=dir
|
||||
[INTACT] 'log' child=1991889 parent=125836432 type=dir
|
||||
[INTACT] 'log' child=2097588 parent=134201370 type=dir
|
||||
[INTACT] 'log' child=2371446 parent=150990428 type=dir
|
||||
[INTACT] 'log' child=2372407 parent=150991423 type=dir
|
||||
[INTACT] 'log' child=2498053 parent=159374142 type=dir
|
||||
[INTACT] 'log' child=2498610 parent=159375135 type=dir
|
||||
[INTACT] 'log' child=2502619 parent=159379170 type=dir
|
||||
[INTACT] 'log' child=3026191 parent=192932796 type=dir
|
||||
[INTACT] 'log' child=3026239 parent=192932938 type=dir
|
||||
[INTACT] 'log' child=3032931 parent=192939639 type=dir
|
||||
[INTACT] 'log' child=3149355 parent=201313102 type=dir
|
||||
[INTACT] 'log' child=3151959 parent=201315933 type=dir
|
||||
[INTACT] 'log' child=3156862 parent=201320877 type=dir
|
||||
[INTACT] 'log' child=3160172 parent=201324390 type=dir
|
||||
[INTACT] 'log' child=3164477 parent=201328491 type=dir
|
||||
[INTACT] 'log' child=3415134 parent=218093944 type=dir
|
||||
[INTACT] 'log' child=3415175 parent=218094502 type=dir
|
||||
[INTACT] 'log' child=3430467 parent=218107906 type=dir
|
||||
[INTACT] 'log' child=3539730 parent=226476593 type=dir
|
||||
[INTACT] 'log' child=3540439 parent=226477266 type=dir
|
||||
[INTACT] 'log' child=3541853 parent=226477131 type=dir
|
||||
[INTACT] 'log' child=3542407 parent=226479225 type=dir
|
||||
[INTACT] 'log' child=3543789 parent=226480565 type=dir
|
||||
[INTACT] 'log' child=3671475 parent=234865777 type=dir
|
||||
[INTACT] 'log' child=3672438 parent=234866814 type=dir
|
||||
[INTACT] 'log' child=3822192 parent=243271285 type=dir
|
||||
[INTACT] 'log' child=3942248 parent=251649384 type=dir
|
||||
[INTACT] 'log' child=3952497 parent=251661968 type=dir
|
||||
[INTACT] 'log' child=3964640 parent=251673996 type=dir
|
||||
[INTACT] 'log' child=4335460 parent=276815206 type=dir
|
||||
[INTACT] 'log' child=4345359 parent=276826952 type=dir
|
||||
[INTACT] 'log' child=4345911 parent=276827945 type=dir
|
||||
[INTACT] 'log' child=4347291 parent=276829282 type=dir
|
||||
[INTACT] 'log' child=4348236 parent=276830303 type=dir
|
||||
[INTACT] 'log' child=4468198 parent=285205428 type=dir
|
||||
[INTACT] 'log' child=4473829 parent=100648635 type=dir
|
||||
[INTACT] 'log' child=4587550 parent=285229071 type=dir
|
||||
[INTACT] 'log' child=9044379 parent=210223241 type=dir
|
||||
[INTACT] 'log' child=12204268 parent=210329845 type=dir
|
||||
[INTACT] 'log' child=14955773 parent=210360481 type=dir
|
||||
[INTACT] 'log' child=15082000 parent=210615907 type=dir
|
||||
[INTACT] 'log' child=15349367 parent=210324339 type=dir
|
||||
[INTACT] 'log' child=17055930 parent=210685762 type=dir
|
||||
[INTACT] 'log' child=19936354 parent=210594543 type=dir
|
||||
[INTACT] 'mysql' child=1315529 parent=83874094 type=dir
|
||||
[INTACT] 'mysql' child=1591190 parent=100664840 type=dir
|
||||
[INTACT] 'mysql' child=1593996 parent=100667804 type=dir
|
||||
[INTACT] 'mysql' child=1594026 parent=100666127 type=link
|
||||
[INTACT] 'mysql' child=1976852 parent=125823110 type=dir
|
||||
[INTACT] 'mysql' child=1979688 parent=125824397 type=link
|
||||
[INTACT] 'mysql' child=2385299 parent=151004317 type=dir
|
||||
[INTACT] 'mysql' child=2754290 parent=176145895 type=dir
|
||||
[INTACT] 'mysql' child=3164741 parent=201328825 type=dir
|
||||
[INTACT] 'mysql' child=3280643 parent=209702403 type=dir
|
||||
[INTACT] 'mysql' child=3281131 parent=209702916 type=dir
|
||||
[INTACT] 'mysql' child=3544957 parent=226481649 type=dir
|
||||
[INTACT] 'mysql' child=3802782 parent=243254702 type=dir
|
||||
[INTACT] 'mysql' child=3946290 parent=251655740 type=dir
|
||||
[INTACT] 'mysql' child=3955138 parent=251664296 type=dir
|
||||
[INTACT] 'mysql' child=3961819 parent=251671257 type=dir
|
||||
[INTACT] 'mysql' child=3961890 parent=251668087 type=link
|
||||
[INTACT] 'mysql' child=4194328 parent=268418850 type=dir
|
||||
[INTACT] 'mysql' child=4339502 parent=276821560 type=dir
|
||||
[INTACT] 'mysql' child=4458148 parent=210467478 type=dir
|
||||
[INTACT] 'mysql' child=4458283 parent=210493665 type=dir
|
||||
[INTACT] 'mysql' child=4472275 parent=285211867 type=dir
|
||||
[INTACT] 'mysql' child=9046451 parent=210222501 type=dir
|
||||
[INTACT] 'mysql' child=13373267 parent=212052530 type=dir
|
||||
[INTACT] 'mysql' child=49414148 parent=210523963 type=dir
|
||||
[INTACT] 'pterodactyl' child=1574102 parent=100647925 type=dir
|
||||
[INTACT] 'pterodactyl' child=2502697 parent=159379272 type=dir
|
||||
[INTACT] 'pterodactyl' child=2502709 parent=159379273 type=link
|
||||
[INTACT] 'pterodactyl' child=4459214 parent=210493665 type=dir
|
||||
268
test/inode.list2.txt
Normal file
268
test/inode.list2.txt
Normal file
@@ -0,0 +1,268 @@
|
||||
Device: /dev/nbd0
|
||||
Scanning groups 13 to 35727...
|
||||
|
||||
Group 1000/35728...
|
||||
Group 2000/35728...
|
||||
Group 3000/35728...
|
||||
Group 4000/35728...
|
||||
Group 5000/35728...
|
||||
Group 6000/35728...
|
||||
Group 7000/35728...
|
||||
Group 8000/35728...
|
||||
Group 9000/35728...
|
||||
Group 10000/35728...
|
||||
[INTACT] 'mysql' child= 1315529 parent= 83874094 type=dir
|
||||
Group 11000/35728...
|
||||
[INTACT] 'apache2' child= 1441863 parent= 92258147 type=dir
|
||||
Group 12000/35728...
|
||||
[INTACT] 'apache2' child= 1572931 parent= 100646751 type=dir
|
||||
[INTACT] 'pterodactyl' child= 1574102 parent= 100647925 type=dir
|
||||
[INTACT] 'log' child= 4473829 parent= 100648635 type=dir
|
||||
[INTACT] 'log' child= 1590880 parent= 100664187 type=dir
|
||||
[INTACT] 'log' child= 1590928 parent= 100664751 type=dir
|
||||
[INTACT] 'archives' child= 1590964 parent= 100664787 type=dir
|
||||
[INTACT] 'mysql' child= 1591190 parent= 100664840 type=dir
|
||||
[INTACT] 'mysql' child= 1594026 parent= 100666127 type=link
|
||||
[INTACT] 'mysql' child= 1593996 parent= 100667804 type=dir
|
||||
[INTACT] 'log' child= 1703998 parent= 100678162 type=dir
|
||||
Group 13000/35728...
|
||||
[INTACT] 'docker' child= 1704222 parent= 109035581 type=file
|
||||
[INTACT] 'log' child= 1708761 parent= 109039668 type=dir
|
||||
[INTACT] 'log' child= 1713577 parent= 109044468 type=dir
|
||||
[INTACT] 'archives' child= 1713111 parent= 109044470 type=dir
|
||||
Group 14000/35728...
|
||||
Group 15000/35728...
|
||||
[INTACT] 'archives' child= 1976627 parent= 125823058 type=dir
|
||||
[INTACT] 'mysql' child= 1976852 parent= 125823110 type=dir
|
||||
[INTACT] 'mysql' child= 1979688 parent= 125824397 type=link
|
||||
[INTACT] 'log' child= 1991889 parent= 125836432 type=dir
|
||||
Group 16000/35728...
|
||||
[INTACT] 'log' child= 2097588 parent= 134201370 type=dir
|
||||
Group 17000/35728...
|
||||
Group 18000/35728...
|
||||
[INTACT] 'log' child= 2371446 parent= 150990428 type=dir
|
||||
[INTACT] 'archives' child= 2371391 parent= 150990430 type=dir
|
||||
[INTACT] 'log' child= 2372407 parent= 150991423 type=dir
|
||||
[INTACT] 'mysql' child= 2385299 parent= 151004317 type=dir
|
||||
Group 19000/35728...
|
||||
[INTACT] 'log' child= 2498053 parent= 159374142 type=dir
|
||||
[INTACT] 'log' child= 2498610 parent= 159375135 type=dir
|
||||
[INTACT] 'archives' child= 2498562 parent= 159375137 type=dir
|
||||
[INTACT] 'log' child= 2502619 parent= 159379170 type=dir
|
||||
[INTACT] 'archives' child= 2502597 parent= 159379172 type=dir
|
||||
[INTACT] 'pterodactyl' child= 2502697 parent= 159379272 type=dir
|
||||
[INTACT] 'pterodactyl' child= 2502709 parent= 159379273 type=link
|
||||
Group 20000/35728...
|
||||
Group 21000/35728...
|
||||
[INTACT] 'mysql' child= 2754290 parent= 176145895 type=dir
|
||||
Group 22000/35728...
|
||||
Group 23000/35728...
|
||||
[INTACT] 'log' child= 3026191 parent= 192932796 type=dir
|
||||
[INTACT] 'archives' child= 3026079 parent= 192932798 type=dir
|
||||
[INTACT] 'log' child= 3026239 parent= 192932938 type=dir
|
||||
[INTACT] 'docker' child= 3026262 parent= 192932981 type=file
|
||||
[INTACT] 'log' child= 3032931 parent= 192939639 type=dir
|
||||
Group 24000/35728...
|
||||
[INTACT] 'log' child= 3149355 parent= 201313102 type=dir
|
||||
[INTACT] 'archives' child= 3148850 parent= 201313105 type=dir
|
||||
[INTACT] 'log' child= 3151959 parent= 201315933 type=dir
|
||||
[INTACT] 'log' child= 3156862 parent= 201320877 type=dir
|
||||
[INTACT] 'log' child= 3160172 parent= 201324390 type=dir
|
||||
[INTACT] 'archives' child= 3160137 parent= 201324392 type=dir
|
||||
[INTACT] 'log' child= 3164477 parent= 201328491 type=dir
|
||||
[INTACT] 'mysql' child= 3164741 parent= 201328825 type=dir
|
||||
Group 25000/35728...
|
||||
[INTACT] 'mysql' child= 3280643 parent= 209702403 type=dir
|
||||
[INTACT] 'mysql' child= 3281131 parent= 209702916 type=dir
|
||||
[INTACT] 'mysql' child= 9046451 parent= 210222501 type=dir
|
||||
[INTACT] 'log' child= 9044379 parent= 210223241 type=dir
|
||||
[INTACT] 'commons-codec' child= 17313577 parent= 210279436 type=dir
|
||||
[INTACT] 'log' child= 15349367 parent= 210324339 type=dir
|
||||
[INTACT] 'log' child= 12204268 parent= 210329845 type=dir
|
||||
[INTACT] 'log' child= 14955773 parent= 210360481 type=dir
|
||||
[INTACT] 'archives' child= 14953284 parent= 210360483 type=dir
|
||||
[INTACT] 'mysql' child= 4458148 parent= 210467478 type=dir
|
||||
[INTACT] 'pterodactyl' child= 4459214 parent= 210493665 type=dir
|
||||
[INTACT] 'docker' child= 4459256 parent= 210493665 type=dir
|
||||
[INTACT] 'mysql' child= 4458283 parent= 210493665 type=dir
|
||||
[INTACT] 'apache2' child= 4458419 parent= 210493665 type=dir
|
||||
[INTACT] 'mysql' child= 49414148 parent= 210523963 type=dir
|
||||
[INTACT] 'log' child= 19936354 parent= 210594543 type=dir
|
||||
[INTACT] 'archives' child= 15368106 parent= 210596329 type=dir
|
||||
[INTACT] 'archives' child= 16001027 parent= 210609986 type=dir
|
||||
[INTACT] 'log' child= 15082000 parent= 210615907 type=dir
|
||||
[INTACT] 'log' child= 17055930 parent= 210685762 type=dir
|
||||
[INTACT] 'archives' child= 14821978 parent= 210685769 type=dir
|
||||
[INTACT] 'apache2' child= 15732946 parent= 210686638 type=dir
|
||||
[INTACT] 'archives' child= 14967386 parent= 210699927 type=dir
|
||||
[INTACT] 'mysql' child= 13373267 parent= 212052530 type=dir
|
||||
Group 26000/35728...
|
||||
[INTACT] 'docker' child= 3410441 parent= 218089768 type=file
|
||||
[INTACT] 'log' child= 3415134 parent= 218093944 type=dir
|
||||
[INTACT] 'log' child= 3415175 parent= 218094502 type=dir
|
||||
[INTACT] 'log' child= 3430467 parent= 218107906 type=dir
|
||||
Group 27000/35728...
|
||||
[INTACT] 'log' child= 3539730 parent= 226476593 type=dir
|
||||
[INTACT] 'log' child= 3541853 parent= 226477131 type=dir
|
||||
[INTACT] 'archives' child= 3540270 parent= 226477133 type=dir
|
||||
[INTACT] 'log' child= 3540439 parent= 226477266 type=dir
|
||||
[INTACT] 'archives' child= 3540405 parent= 226477268 type=dir
|
||||
[INTACT] 'log' child= 3542407 parent= 226479225 type=dir
|
||||
[INTACT] 'archives' child= 3542364 parent= 226479227 type=dir
|
||||
[INTACT] 'log' child= 3543789 parent= 226480565 type=dir
|
||||
[INTACT] 'mysql' child= 3544957 parent= 226481649 type=dir
|
||||
Group 28000/35728...
|
||||
[INTACT] 'log' child= 3671475 parent= 234865777 type=dir
|
||||
[INTACT] 'archives' child= 3671380 parent= 234865779 type=dir
|
||||
[INTACT] 'log' child= 3672438 parent= 234866814 type=dir
|
||||
[INTACT] 'archives' child= 3672417 parent= 234866816 type=dir
|
||||
Group 29000/35728...
|
||||
[INTACT] 'mysql' child= 3802782 parent= 243254702 type=dir
|
||||
[INTACT] 'log' child= 3822192 parent= 243271285 type=dir
|
||||
Group 30000/35728...
|
||||
[INTACT] 'log' child= 3942248 parent= 251649384 type=dir
|
||||
[INTACT] 'mysql' child= 3946290 parent= 251655740 type=dir
|
||||
[INTACT] 'docker' child= 3947763 parent= 251657234 type=file
|
||||
[INTACT] 'log' child= 3952497 parent= 251661968 type=dir
|
||||
[INTACT] 'mysql' child= 3955138 parent= 251664296 type=dir
|
||||
[INTACT] 'mysql' child= 3961890 parent= 251668087 type=link
|
||||
[INTACT] 'mysql' child= 3961819 parent= 251671257 type=dir
|
||||
[INTACT] 'log' child= 3964640 parent= 251673996 type=dir
|
||||
[INTACT] 'archives' child= 3964527 parent= 251673998 type=dir
|
||||
Group 31000/35728...
|
||||
[INTACT] 'archives' child= 4063735 parent= 260030742 type=dir
|
||||
Group 32000/35728...
|
||||
[INTACT] 'mysql' child= 4194328 parent= 268418850 type=dir
|
||||
Group 33000/35728...
|
||||
[INTACT] 'log' child= 4335460 parent= 276815206 type=dir
|
||||
[INTACT] 'mysql' child= 4339502 parent= 276821560 type=dir
|
||||
[INTACT] 'docker' child= 4341215 parent= 276823294 type=file
|
||||
[INTACT] 'log' child= 4345359 parent= 276826952 type=dir
|
||||
[INTACT] 'log' child= 4345911 parent= 276827945 type=dir
|
||||
[INTACT] 'archives' child= 4345868 parent= 276827947 type=dir
|
||||
[INTACT] 'log' child= 4347291 parent= 276829282 type=dir
|
||||
[INTACT] 'log' child= 4348236 parent= 276830303 type=dir
|
||||
Group 34000/35728...
|
||||
[INTACT] 'apache2' child= 4458777 parent= 285198295 type=dir
|
||||
[INTACT] 'apache2' child= 4459043 parent= 285198521 type=dir
|
||||
[INTACT] 'log' child= 4468198 parent= 285205428 type=dir
|
||||
[INTACT] 'mysql' child= 4472275 parent= 285211867 type=dir
|
||||
[INTACT] 'docker' child= 4485280 parent= 285224895 type=file
|
||||
[INTACT] 'log' child= 4587550 parent= 285229071 type=dir
|
||||
Group 35000/35728...
|
||||
|
||||
=== SUMMARY ===
|
||||
[INTACT] 'apache2' child=1441863 parent=92258147 type=dir
|
||||
[INTACT] 'apache2' child=1572931 parent=100646751 type=dir
|
||||
[INTACT] 'apache2' child=4458419 parent=210493665 type=dir
|
||||
[INTACT] 'apache2' child=4458777 parent=285198295 type=dir
|
||||
[INTACT] 'apache2' child=4459043 parent=285198521 type=dir
|
||||
[INTACT] 'apache2' child=15732946 parent=210686638 type=dir
|
||||
[INTACT] 'archives' child=1590964 parent=100664787 type=dir
|
||||
[INTACT] 'archives' child=1713111 parent=109044470 type=dir
|
||||
[INTACT] 'archives' child=1976627 parent=125823058 type=dir
|
||||
[INTACT] 'archives' child=2371391 parent=150990430 type=dir
|
||||
[INTACT] 'archives' child=2498562 parent=159375137 type=dir
|
||||
[INTACT] 'archives' child=2502597 parent=159379172 type=dir
|
||||
[INTACT] 'archives' child=3026079 parent=192932798 type=dir
|
||||
[INTACT] 'archives' child=3148850 parent=201313105 type=dir
|
||||
[INTACT] 'archives' child=3160137 parent=201324392 type=dir
|
||||
[INTACT] 'archives' child=3540270 parent=226477133 type=dir
|
||||
[INTACT] 'archives' child=3540405 parent=226477268 type=dir
|
||||
[INTACT] 'archives' child=3542364 parent=226479227 type=dir
|
||||
[INTACT] 'archives' child=3671380 parent=234865779 type=dir
|
||||
[INTACT] 'archives' child=3672417 parent=234866816 type=dir
|
||||
[INTACT] 'archives' child=3964527 parent=251673998 type=dir
|
||||
[INTACT] 'archives' child=4063735 parent=260030742 type=dir
|
||||
[INTACT] 'archives' child=4345868 parent=276827947 type=dir
|
||||
[INTACT] 'archives' child=14821978 parent=210685769 type=dir
|
||||
[INTACT] 'archives' child=14953284 parent=210360483 type=dir
|
||||
[INTACT] 'archives' child=14967386 parent=210699927 type=dir
|
||||
[INTACT] 'archives' child=15368106 parent=210596329 type=dir
|
||||
[INTACT] 'archives' child=16001027 parent=210609986 type=dir
|
||||
[INTACT] 'commons-codec' child=17313577 parent=210279436 type=dir
|
||||
[INTACT] 'docker' child=1704222 parent=109035581 type=file
|
||||
[INTACT] 'docker' child=3026262 parent=192932981 type=file
|
||||
[INTACT] 'docker' child=3410441 parent=218089768 type=file
|
||||
[INTACT] 'docker' child=3947763 parent=251657234 type=file
|
||||
[INTACT] 'docker' child=4341215 parent=276823294 type=file
|
||||
[INTACT] 'docker' child=4459256 parent=210493665 type=dir
|
||||
[INTACT] 'docker' child=4485280 parent=285224895 type=file
|
||||
[INTACT] 'log' child=1590880 parent=100664187 type=dir
|
||||
[INTACT] 'log' child=1590928 parent=100664751 type=dir
|
||||
[INTACT] 'log' child=1703998 parent=100678162 type=dir
|
||||
[INTACT] 'log' child=1708761 parent=109039668 type=dir
|
||||
[INTACT] 'log' child=1713577 parent=109044468 type=dir
|
||||
[INTACT] 'log' child=1991889 parent=125836432 type=dir
|
||||
[INTACT] 'log' child=2097588 parent=134201370 type=dir
|
||||
[INTACT] 'log' child=2371446 parent=150990428 type=dir
|
||||
[INTACT] 'log' child=2372407 parent=150991423 type=dir
|
||||
[INTACT] 'log' child=2498053 parent=159374142 type=dir
|
||||
[INTACT] 'log' child=2498610 parent=159375135 type=dir
|
||||
[INTACT] 'log' child=2502619 parent=159379170 type=dir
|
||||
[INTACT] 'log' child=3026191 parent=192932796 type=dir
|
||||
[INTACT] 'log' child=3026239 parent=192932938 type=dir
|
||||
[INTACT] 'log' child=3032931 parent=192939639 type=dir
|
||||
[INTACT] 'log' child=3149355 parent=201313102 type=dir
|
||||
[INTACT] 'log' child=3151959 parent=201315933 type=dir
|
||||
[INTACT] 'log' child=3156862 parent=201320877 type=dir
|
||||
[INTACT] 'log' child=3160172 parent=201324390 type=dir
|
||||
[INTACT] 'log' child=3164477 parent=201328491 type=dir
|
||||
[INTACT] 'log' child=3415134 parent=218093944 type=dir
|
||||
[INTACT] 'log' child=3415175 parent=218094502 type=dir
|
||||
[INTACT] 'log' child=3430467 parent=218107906 type=dir
|
||||
[INTACT] 'log' child=3539730 parent=226476593 type=dir
|
||||
[INTACT] 'log' child=3540439 parent=226477266 type=dir
|
||||
[INTACT] 'log' child=3541853 parent=226477131 type=dir
|
||||
[INTACT] 'log' child=3542407 parent=226479225 type=dir
|
||||
[INTACT] 'log' child=3543789 parent=226480565 type=dir
|
||||
[INTACT] 'log' child=3671475 parent=234865777 type=dir
|
||||
[INTACT] 'log' child=3672438 parent=234866814 type=dir
|
||||
[INTACT] 'log' child=3822192 parent=243271285 type=dir
|
||||
[INTACT] 'log' child=3942248 parent=251649384 type=dir
|
||||
[INTACT] 'log' child=3952497 parent=251661968 type=dir
|
||||
[INTACT] 'log' child=3964640 parent=251673996 type=dir
|
||||
[INTACT] 'log' child=4335460 parent=276815206 type=dir
|
||||
[INTACT] 'log' child=4345359 parent=276826952 type=dir
|
||||
[INTACT] 'log' child=4345911 parent=276827945 type=dir
|
||||
[INTACT] 'log' child=4347291 parent=276829282 type=dir
|
||||
[INTACT] 'log' child=4348236 parent=276830303 type=dir
|
||||
[INTACT] 'log' child=4468198 parent=285205428 type=dir
|
||||
[INTACT] 'log' child=4473829 parent=100648635 type=dir
|
||||
[INTACT] 'log' child=4587550 parent=285229071 type=dir
|
||||
[INTACT] 'log' child=9044379 parent=210223241 type=dir
|
||||
[INTACT] 'log' child=12204268 parent=210329845 type=dir
|
||||
[INTACT] 'log' child=14955773 parent=210360481 type=dir
|
||||
[INTACT] 'log' child=15082000 parent=210615907 type=dir
|
||||
[INTACT] 'log' child=15349367 parent=210324339 type=dir
|
||||
[INTACT] 'log' child=17055930 parent=210685762 type=dir
|
||||
[INTACT] 'log' child=19936354 parent=210594543 type=dir
|
||||
[INTACT] 'mysql' child=1315529 parent=83874094 type=dir
|
||||
[INTACT] 'mysql' child=1591190 parent=100664840 type=dir
|
||||
[INTACT] 'mysql' child=1593996 parent=100667804 type=dir
|
||||
[INTACT] 'mysql' child=1594026 parent=100666127 type=link
|
||||
[INTACT] 'mysql' child=1976852 parent=125823110 type=dir
|
||||
[INTACT] 'mysql' child=1979688 parent=125824397 type=link
|
||||
[INTACT] 'mysql' child=2385299 parent=151004317 type=dir
|
||||
[INTACT] 'mysql' child=2754290 parent=176145895 type=dir
|
||||
[INTACT] 'mysql' child=3164741 parent=201328825 type=dir
|
||||
[INTACT] 'mysql' child=3280643 parent=209702403 type=dir
|
||||
[INTACT] 'mysql' child=3281131 parent=209702916 type=dir
|
||||
[INTACT] 'mysql' child=3544957 parent=226481649 type=dir
|
||||
[INTACT] 'mysql' child=3802782 parent=243254702 type=dir
|
||||
[INTACT] 'mysql' child=3946290 parent=251655740 type=dir
|
||||
[INTACT] 'mysql' child=3955138 parent=251664296 type=dir
|
||||
[INTACT] 'mysql' child=3961819 parent=251671257 type=dir
|
||||
[INTACT] 'mysql' child=3961890 parent=251668087 type=link
|
||||
[INTACT] 'mysql' child=4194328 parent=268418850 type=dir
|
||||
[INTACT] 'mysql' child=4339502 parent=276821560 type=dir
|
||||
[INTACT] 'mysql' child=4458148 parent=210467478 type=dir
|
||||
[INTACT] 'mysql' child=4458283 parent=210493665 type=dir
|
||||
[INTACT] 'mysql' child=4472275 parent=285211867 type=dir
|
||||
[INTACT] 'mysql' child=9046451 parent=210222501 type=dir
|
||||
[INTACT] 'mysql' child=13373267 parent=212052530 type=dir
|
||||
[INTACT] 'mysql' child=49414148 parent=210523963 type=dir
|
||||
[INTACT] 'pterodactyl' child=1574102 parent=100647925 type=dir
|
||||
[INTACT] 'pterodactyl' child=2502697 parent=159379272 type=dir
|
||||
[INTACT] 'pterodactyl' child=2502709 parent=159379273 type=link
|
||||
[INTACT] 'pterodactyl' child=4459214 parent=210493665 type=dir
|
||||
88
test/inspect.py
Normal file
88
test/inspect.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import struct
|
||||
|
||||
CHUNK = 128 * 512
|
||||
LV_START = 5120000 * 512
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
INODE_SIZE = 256
|
||||
|
||||
def read_virt(virt_offset, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open('/dev/nbd0', 'rb') as f:
|
||||
while remaining > 0:
|
||||
f.seek(pos)
|
||||
chunk = f.read(min(remaining, 65536))
|
||||
if not chunk: break
|
||||
dst = pos - virt_offset
|
||||
result[dst:dst+len(chunk)] = chunk
|
||||
pos += len(chunk)
|
||||
remaining -= len(chunk)
|
||||
return bytes(result)
|
||||
|
||||
def get_inode_table_block(group):
|
||||
# Use backup GDT at group 1
|
||||
gdt_start = (1 * 32768 + 1) * BSIZE
|
||||
entry = read_virt(gdt_start + group * 64, 64)
|
||||
it_lo = struct.unpack_from('<I', entry, 8)[0]
|
||||
it_hi = struct.unpack_from('<I', entry, 40)[0]
|
||||
return (it_hi << 32) | it_lo
|
||||
|
||||
def read_inode(inode_num):
|
||||
group = (inode_num - 1) // IPG
|
||||
index = (inode_num - 1) % IPG
|
||||
it_block = get_inode_table_block(group)
|
||||
inode_off = it_block * BSIZE + index * INODE_SIZE
|
||||
return read_virt(inode_off, INODE_SIZE)
|
||||
|
||||
def read_extents(inode_data):
|
||||
blocks = []
|
||||
eh_magic = struct.unpack_from('<H', inode_data, 40)[0]
|
||||
if eh_magic != 0xf30a:
|
||||
return blocks
|
||||
eh_entries = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
eh_depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
if eh_depth == 0:
|
||||
for i in range(min(eh_entries, 4)):
|
||||
off = 52 + i * 12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_start_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_start_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
ee_start = (ee_start_hi << 32) | ee_start_lo
|
||||
for b in range(ee_len):
|
||||
blocks.append(ee_start + b)
|
||||
return blocks
|
||||
|
||||
def list_dir(inode_num):
|
||||
inode_data = read_inode(inode_num)
|
||||
mode = struct.unpack_from('<H', inode_data, 0)[0]
|
||||
size = struct.unpack_from('<I', inode_data, 4)[0]
|
||||
links = struct.unpack_from('<H', inode_data, 26)[0]
|
||||
print(f'Inode {inode_num}: mode=0x{mode:04x} size={size} links={links}')
|
||||
|
||||
entries = []
|
||||
for block_num in read_extents(inode_data):
|
||||
data = read_virt(block_num * BSIZE, BSIZE)
|
||||
off = 0
|
||||
while off < BSIZE - 8:
|
||||
ino = struct.unpack_from('<I', data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', data, off+4)[0]
|
||||
name_len = data[off+6]
|
||||
ftype = data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
grp = (ino-1)//IPG
|
||||
entries.append((name, ino, ftype, grp))
|
||||
off += rec_len
|
||||
|
||||
type_names = {1:'file',2:'dir',7:'symlink'}
|
||||
print(f'Directory entries ({len(entries)}):')
|
||||
for name, ino, ftype, grp in sorted(entries):
|
||||
status = 'INTACT' if grp >= 13 else 'LOST'
|
||||
tname = type_names.get(ftype, str(ftype))
|
||||
print(f' [{status}] {tname:6s} inode={ino:10d} group={grp:6d} {name!r}')
|
||||
|
||||
# Read the volumes directory
|
||||
list_dir(1585918)
|
||||
21
test/jj.sh
Normal file
21
test/jj.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
python3 -c "
|
||||
# Read actual PERC metadata chunks - these are NOT zeros
|
||||
# They're at every 5th chunk position on each physical disk
|
||||
# For disk 0 (sda), metadata chunks are at:
|
||||
# phys_byte = data_offset + group*5*CHUNK + 4*CHUNK
|
||||
# i.e., chunk positions 4, 9, 14, 19... of each disk
|
||||
|
||||
CHUNK = 128*512 # 64KB
|
||||
LV_START = 5120000*512
|
||||
|
||||
# Read first few metadata chunks from sda
|
||||
with open('/dev/sda','rb') as f:
|
||||
for chunk_num in [4, 9, 14, 19, 24]:
|
||||
phys = LV_START + chunk_num * CHUNK
|
||||
f.seek(phys)
|
||||
data = f.read(512)
|
||||
nonzero = sum(1 for b in data if b != 0)
|
||||
print(f'sda metadata chunk {chunk_num}: '
|
||||
f'phys={phys} nonzero={nonzero}/512 '
|
||||
f'first8={data[:8].hex()}')
|
||||
"
|
||||
57
test/k.sh
Normal file
57
test/k.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
python3 -c "
|
||||
import struct
|
||||
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(1024)
|
||||
sb = f.read(1024)
|
||||
|
||||
# Print all key superblock fields
|
||||
fields = [
|
||||
('inodes_count', 0, 'I'),
|
||||
('blocks_count_lo', 4, 'I'),
|
||||
('free_blocks_lo', 12, 'I'),
|
||||
('free_inodes', 16, 'I'),
|
||||
('first_data_block', 20, 'I'),
|
||||
('log_block_size', 24, 'I'), # bsize = 1024 << log_block_size
|
||||
('blocks_per_group', 32, 'I'), # NOTE: offset 32 not 40
|
||||
('inodes_per_group', 40, 'I'), # NOTE: offset 40
|
||||
('magic', 56, 'H'),
|
||||
('state', 58, 'H'),
|
||||
('inode_size', 88, 'H'),
|
||||
('block_group_nr', 90, 'H'),
|
||||
('feat_compat', 92, 'I'),
|
||||
('feat_incompat', 96, 'I'),
|
||||
('feat_ro_compat', 100, 'I'),
|
||||
('journal_inum', 180, 'I'),
|
||||
('blocks_per_group_2', 32, 'I'),
|
||||
('desc_size', 254, 'H'), # GDT entry size
|
||||
('blocks_count_hi', 336, 'I'),
|
||||
]
|
||||
|
||||
print('Superblock fields:')
|
||||
for name, off, fmt in fields:
|
||||
size = struct.calcsize('<'+fmt)
|
||||
val = struct.unpack_from('<'+fmt, sb, off)[0]
|
||||
print(f' {name:25s} @ offset {off:3d}: {val}')
|
||||
|
||||
# Recalculate key values
|
||||
log_bsize = struct.unpack_from('<I', sb, 24)[0]
|
||||
bsize = 1024 << log_bsize
|
||||
bpg = struct.unpack_from('<I', sb, 32)[0]
|
||||
ipg = struct.unpack_from('<I', sb, 40)[0]
|
||||
desc_size = struct.unpack_from('<H', sb, 254)[0]
|
||||
total_blocks_lo = struct.unpack_from('<I', sb, 4)[0]
|
||||
total_blocks_hi = struct.unpack_from('<I', sb, 336)[0]
|
||||
total_blocks = (total_blocks_hi << 32) | total_blocks_lo
|
||||
num_groups = (total_blocks + bpg - 1) // bpg
|
||||
|
||||
print()
|
||||
print(f'Computed values:')
|
||||
print(f' block size: {bsize}')
|
||||
print(f' blocks/group: {bpg}')
|
||||
print(f' inodes/group: {ipg}')
|
||||
print(f' GDT entry size: {desc_size}')
|
||||
print(f' total blocks: {total_blocks}')
|
||||
print(f' num groups: {num_groups}')
|
||||
print(f' GDT size: {num_groups * desc_size} bytes')
|
||||
"
|
||||
51
test/kk.sh
Normal file
51
test/kk.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
python3 -c "
|
||||
CHUNK = 128*512 # 64KB
|
||||
LV_START = 5120000*512
|
||||
|
||||
with open('/dev/sda','rb') as f:
|
||||
# Read full first metadata chunk
|
||||
phys = LV_START + 4 * CHUNK
|
||||
f.seek(phys)
|
||||
data = f.read(CHUNK)
|
||||
|
||||
# What's in it?
|
||||
import struct
|
||||
|
||||
# Check for recognizable patterns
|
||||
print(f'First 64 bytes:')
|
||||
for i in range(0, 64, 16):
|
||||
print(f' {data[i:i+16].hex()} {data[i:i+16].decode(\"latin1\",errors=\"replace\")}')
|
||||
|
||||
# Check if it repeats
|
||||
chunk_size = len(data)
|
||||
period = None
|
||||
for p in [8, 16, 32, 64, 128, 256, 512]:
|
||||
if data[:p] * (chunk_size // p) == data[:chunk_size - chunk_size%p]:
|
||||
period = p
|
||||
print(f'Data repeats every {p} bytes')
|
||||
break
|
||||
|
||||
# Check if first 8 bytes appear elsewhere in the chunk
|
||||
pattern = data[:8]
|
||||
count = 0
|
||||
pos = 0
|
||||
while True:
|
||||
idx = data.find(pattern, pos)
|
||||
if idx < 0: break
|
||||
count += 1
|
||||
pos = idx + 1
|
||||
print(f'First 8 bytes appear {count} times in chunk')
|
||||
|
||||
# Check spacing between repetitions
|
||||
positions = []
|
||||
pos = 0
|
||||
while True:
|
||||
idx = data.find(pattern, pos)
|
||||
if idx < 0: break
|
||||
positions.append(idx)
|
||||
pos = idx + 1
|
||||
if len(positions) > 1:
|
||||
gaps = [positions[i+1]-positions[i] for i in range(len(positions)-1)]
|
||||
print(f'Positions: {positions[:10]}')
|
||||
print(f'Gaps: {gaps[:10]}')
|
||||
"
|
||||
45
test/l.sh
Normal file
45
test/l.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
python3 -c "
|
||||
import struct, crcmod
|
||||
|
||||
crc32c = crcmod.predefined.mkCrcFun('crc-32c')
|
||||
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(1024)
|
||||
sb = f.read(1024)
|
||||
|
||||
uuid = sb[104:120]
|
||||
csum_seed = struct.unpack_from('<I', sb, 408)[0]
|
||||
print(f'uuid: {uuid.hex()}')
|
||||
print(f'csum_seed: 0x{csum_seed:08x}')
|
||||
|
||||
# Read current group 0 GDT entry
|
||||
data = bytearray(open('/tmp/merged_gdt.bin','rb').read())
|
||||
e = bytearray(data[0:64])
|
||||
print(f'Group 0 entry: {e.hex()}')
|
||||
print(f'Current stored csum: 0x{struct.unpack_from(\"<H\",e,30)[0]:04x}')
|
||||
print(f'e2fsck says should be: 0x03f5')
|
||||
|
||||
# Try different computation methods
|
||||
# Method 1: standard
|
||||
e2 = bytearray(e); struct.pack_into('<H',e2,30,0)
|
||||
c1 = crc32c(uuid + struct.pack('<H',0) + bytes(e2), csum_seed) & 0xFFFF
|
||||
print(f'Method 1 (seed+uuid+grp+entry): 0x{c1:04x}')
|
||||
|
||||
# Method 2: no seed
|
||||
c2 = crc32c(uuid + struct.pack('<H',0) + bytes(e2)) & 0xFFFF
|
||||
print(f'Method 2 (no seed): 0x{c2:04x}')
|
||||
|
||||
# Method 3: seed only on uuid
|
||||
c3 = crc32c(struct.pack('<H',0) + bytes(e2), crc32c(uuid, csum_seed)) & 0xFFFF
|
||||
print(f'Method 3 (seed on uuid first): 0x{c3:04x}')
|
||||
|
||||
# Method 4: what e2fsck uses internally
|
||||
# From e2fsprogs source: crc32c(~0, uuid, 16) then crc32c(that, grp_le16+entry)
|
||||
seed = crc32c(uuid, 0xFFFFFFFF)
|
||||
c4 = crc32c(struct.pack('<H',0) + bytes(e2), seed) & 0xFFFF
|
||||
print(f'Method 4 (e2fsprogs source): 0x{c4:04x}')
|
||||
|
||||
# Method 5: using stored seed from superblock offset 408
|
||||
c5 = crc32c(struct.pack('<H',0) + bytes(e2), csum_seed) & 0xFFFF
|
||||
print(f'Method 5 (stored seed only): 0x{c5:04x}')
|
||||
"
|
||||
340
test/nbd_server_v3
Normal file
340
test/nbd_server_v3
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NBD server v3 — newstyle protocol + PERC H710 chunk translation.
|
||||
|
||||
Transformations applied on every read:
|
||||
1. Chunk translation — skips every 5th 64KB chunk (PERC internal metadata)
|
||||
2. Superblock patch — clears metadata_csum / gdt_csum / has_journal bits
|
||||
3. GDT reconstruction — synthesizes correct group descriptors for regions
|
||||
that fall inside metadata chunks
|
||||
|
||||
Usage:
|
||||
python3 nbd_server_v3.py &
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
# ── Physical layout ───────────────────────────────────────────────────────────
|
||||
DEV = '/dev/md0'
|
||||
CHUNK_BYTES = 128 * 512 # 64 KB
|
||||
LV_PHYS_START = 5120000 * 512 # byte 2,621,440,000
|
||||
VIRT_SIZE = 9365766144 * 512 # from superblock block count
|
||||
|
||||
# ── ext4 filesystem parameters ────────────────────────────────────────────────
|
||||
BSIZE = 4096
|
||||
BPG = 32768
|
||||
GDT_ENTRY = 64
|
||||
NUM_GROUPS = 35728
|
||||
|
||||
GDT_START_VIRT = BSIZE
|
||||
GDT_END_VIRT = BSIZE + NUM_GROUPS * GDT_ENTRY
|
||||
|
||||
SB_VIRT_OFFSET = 1024
|
||||
SB_SIZE = 1024
|
||||
|
||||
SB_INCOMPAT_OFF = 96
|
||||
SB_RO_COMPAT_OFF = 100
|
||||
SB_CHECKSUM_OFF = 1020
|
||||
|
||||
INCOMPAT_HAS_JOURNAL = 0x00000004
|
||||
RO_COMPAT_METADATA_CSUM = 0x00000400
|
||||
RO_COMPAT_GDT_CSUM = 0x00000010
|
||||
|
||||
_patched_sb = None
|
||||
_sb_lock = threading.Lock()
|
||||
|
||||
# ── NBD newstyle protocol constants ──────────────────────────────────────────
|
||||
NBDMAGIC = 0x4e42444d41474943 # "NBDMAGIC"
|
||||
IHAVEOPT = 0x49484156454F5054 # "IHAVEOPT"
|
||||
REPLYMAGIC = 0x3e889045565a9
|
||||
|
||||
NBD_OPT_EXPORT_NAME = 1
|
||||
NBD_OPT_ABORT = 2
|
||||
NBD_OPT_LIST = 3
|
||||
NBD_OPT_GO = 7
|
||||
|
||||
NBD_REP_ACK = 1
|
||||
NBD_REP_SERVER = 2
|
||||
NBD_REP_ERR_UNSUP = (1 << 31) | 1
|
||||
NBD_REP_ERR_POLICY = (1 << 31) | 2
|
||||
|
||||
NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
NBD_FLAG_READ_ONLY = 1 << 1
|
||||
NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
NBD_FLAG_FIXED_NEWSTYLE = 1 << 0 # client flag
|
||||
NBD_FLAG_C_NO_ZEROES = 1 << 1 # client flag
|
||||
|
||||
NBD_REQUEST_MAGIC = 0x25609513
|
||||
NBD_REPLY_MAGIC = 0x67446698
|
||||
|
||||
NBD_CMD_READ = 0
|
||||
NBD_CMD_WRITE = 1
|
||||
NBD_CMD_DISC = 2
|
||||
NBD_CMD_FLUSH = 3
|
||||
|
||||
|
||||
# ── Chunk translation ─────────────────────────────────────────────────────────
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open(DEV, 'rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
|
||||
if chunk_idx != 4:
|
||||
phys = (LV_PHYS_START
|
||||
+ group * 4 * CHUNK_BYTES
|
||||
+ chunk_idx * CHUNK_BYTES
|
||||
+ intra)
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off + len(data)] = data
|
||||
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
|
||||
# ── GDT synthesis ─────────────────────────────────────────────────────────────
|
||||
|
||||
def make_gdt_entry(n):
|
||||
"""Build 64-byte group descriptor for group n using confirmed pattern."""
|
||||
gd = bytearray(GDT_ENTRY)
|
||||
struct.pack_into('<I', gd, 0, 1038 + n) # block_bitmap_lo
|
||||
struct.pack_into('<I', gd, 4, 1054 + n) # inode_bitmap_lo
|
||||
struct.pack_into('<I', gd, 8, 1070 + n * 512) # inode_table_lo
|
||||
# free counts = 0, checksum = 0 (metadata_csum cleared)
|
||||
return bytes(gd)
|
||||
|
||||
|
||||
def patch_gdt(data, virt_offset, length):
|
||||
"""Overwrite metadata-chunk zeros within the GDT with synthesized entries."""
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
seg_end = pos + seg_len
|
||||
|
||||
if chunk_idx == 4:
|
||||
# metadata chunk — was zeros; patch if overlaps GDT
|
||||
ol_start = max(pos, GDT_START_VIRT)
|
||||
ol_end = min(seg_end, GDT_END_VIRT)
|
||||
if ol_start < ol_end:
|
||||
for byte_abs in range(ol_start, ol_end):
|
||||
gdt_rel = byte_abs - GDT_START_VIRT
|
||||
grp = gdt_rel // GDT_ENTRY
|
||||
byte_in = gdt_rel % GDT_ENTRY
|
||||
if grp < NUM_GROUPS:
|
||||
dst = byte_abs - virt_offset
|
||||
entry = make_gdt_entry(grp)
|
||||
data[dst] = entry[byte_in]
|
||||
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
|
||||
|
||||
# ── Superblock patch ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_patched_sb():
|
||||
global _patched_sb
|
||||
with _sb_lock:
|
||||
if _patched_sb is not None:
|
||||
return _patched_sb
|
||||
sb = bytearray(raw_read(SB_VIRT_OFFSET, SB_SIZE))
|
||||
incompat = struct.unpack_from('<I', sb, SB_INCOMPAT_OFF)[0]
|
||||
ro_compat = struct.unpack_from('<I', sb, SB_RO_COMPAT_OFF)[0]
|
||||
incompat &= ~INCOMPAT_HAS_JOURNAL
|
||||
ro_compat &= ~(RO_COMPAT_METADATA_CSUM | RO_COMPAT_GDT_CSUM)
|
||||
struct.pack_into('<I', sb, SB_INCOMPAT_OFF, incompat)
|
||||
struct.pack_into('<I', sb, SB_RO_COMPAT_OFF, ro_compat)
|
||||
struct.pack_into('<I', sb, SB_CHECKSUM_OFF, 0)
|
||||
_patched_sb = bytes(sb)
|
||||
print(f'[sb] patched: incompat=0x{incompat:08x} ro_compat=0x{ro_compat:08x}')
|
||||
return _patched_sb
|
||||
|
||||
|
||||
# ── Combined read ─────────────────────────────────────────────────────────────
|
||||
|
||||
def read_virtual(virt_offset, length):
|
||||
data = bytearray(raw_read(virt_offset, length))
|
||||
|
||||
req_end = virt_offset + length
|
||||
|
||||
# Patch superblock
|
||||
sb_s = SB_VIRT_OFFSET
|
||||
sb_e = SB_VIRT_OFFSET + SB_SIZE
|
||||
if virt_offset < sb_e and req_end > sb_s:
|
||||
patched = get_patched_sb()
|
||||
cs = max(virt_offset, sb_s) - virt_offset
|
||||
ce = min(req_end, sb_e) - virt_offset
|
||||
ss = max(virt_offset, sb_s) - sb_s
|
||||
data[cs:ce] = patched[ss:ss + (ce - cs)]
|
||||
|
||||
# Patch GDT (only if request overlaps GDT region)
|
||||
if virt_offset < GDT_END_VIRT and req_end > GDT_START_VIRT:
|
||||
patch_gdt(data, virt_offset, length)
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
# ── NBD newstyle protocol ────────────────────────────────────────────────────
|
||||
|
||||
def recv_all(conn, n):
|
||||
buf = b''
|
||||
while len(buf) < n:
|
||||
chunk = conn.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError('client disconnected')
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def send_reply(conn, opt, reply_type, data=b''):
|
||||
conn.sendall(struct.pack('>QII', REPLYMAGIC, opt, reply_type))
|
||||
conn.sendall(struct.pack('>I', len(data)))
|
||||
if data:
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def send_export_info(conn, no_zeroes=False):
|
||||
"""Send export size + transmission flags."""
|
||||
flags = NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY | NBD_FLAG_SEND_FLUSH
|
||||
conn.sendall(struct.pack('>Q', VIRT_SIZE))
|
||||
conn.sendall(struct.pack('>H', flags))
|
||||
if not no_zeroes:
|
||||
conn.sendall(b'\x00' * 124)
|
||||
|
||||
|
||||
def handle_client(conn, addr):
|
||||
print(f'[nbd] connect from {addr}')
|
||||
no_zeroes = False
|
||||
try:
|
||||
# ── Fixed newstyle handshake ──────────────────────────────────────────
|
||||
# S: magic + IHAVEOPT + server flags
|
||||
conn.sendall(struct.pack('>Q', NBDMAGIC))
|
||||
conn.sendall(struct.pack('>Q', IHAVEOPT))
|
||||
server_flags = NBD_FLAG_HAS_FLAGS | (1 << 0) # FIXED_NEWSTYLE
|
||||
conn.sendall(struct.pack('>H', server_flags))
|
||||
|
||||
# C: client flags
|
||||
client_flags = struct.unpack('>I', recv_all(conn, 4))[0]
|
||||
no_zeroes = bool(client_flags & NBD_FLAG_C_NO_ZEROES)
|
||||
|
||||
# ── Option haggling ───────────────────────────────────────────────────
|
||||
while True:
|
||||
opt_hdr = recv_all(conn, 16)
|
||||
cli_magic, opt, opt_len = struct.unpack('>QII', opt_hdr)
|
||||
opt_data = recv_all(conn, opt_len) if opt_len else b''
|
||||
|
||||
if opt == NBD_OPT_EXPORT_NAME:
|
||||
# Immediate export — no reply, go straight to transmission
|
||||
send_export_info(conn, no_zeroes)
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_GO:
|
||||
# Parse export name (uint32 len + name + info requests)
|
||||
name_len = struct.unpack('>I', opt_data[:4])[0]
|
||||
# Send INFO_EXPORT (type 0)
|
||||
info = struct.pack('>HQH', 0, VIRT_SIZE,
|
||||
NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY | NBD_FLAG_SEND_FLUSH)
|
||||
send_reply(conn, opt, NBD_REP_ACK, info)
|
||||
# After ACK for GO, enter transmission
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_LIST:
|
||||
# Advertise one anonymous export
|
||||
name = b''
|
||||
send_reply(conn, opt, NBD_REP_SERVER,
|
||||
struct.pack('>I', len(name)) + name)
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
|
||||
elif opt == NBD_OPT_ABORT:
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
return
|
||||
|
||||
else:
|
||||
send_reply(conn, opt, NBD_REP_ERR_UNSUP)
|
||||
|
||||
print(f'[nbd] {addr} — entering transmission phase')
|
||||
|
||||
# ── Transmission phase ────────────────────────────────────────────────
|
||||
while True:
|
||||
hdr = recv_all(conn, 28)
|
||||
magic, flags, cmd, handle, offset, length = \
|
||||
struct.unpack('>IHHQQI', hdr)
|
||||
|
||||
if magic != NBD_REQUEST_MAGIC:
|
||||
print(f'[nbd] bad request magic 0x{magic:08x}')
|
||||
return
|
||||
|
||||
if cmd == NBD_CMD_READ:
|
||||
try:
|
||||
payload = read_virtual(offset, length)
|
||||
err = 0
|
||||
except Exception as e:
|
||||
print(f'[nbd] read err offset={offset} len={length}: {e}')
|
||||
payload = b'\x00' * length
|
||||
err = 0
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, err, handle))
|
||||
conn.sendall(payload)
|
||||
|
||||
elif cmd in (NBD_CMD_DISC,):
|
||||
print(f'[nbd] {addr} disconnected')
|
||||
return
|
||||
|
||||
elif cmd == NBD_CMD_FLUSH:
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle))
|
||||
|
||||
else:
|
||||
# Write or unknown — return EPERM
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 1, handle))
|
||||
|
||||
except (ConnectionError, BrokenPipeError, ConnectionResetError):
|
||||
print(f'[nbd] {addr} dropped')
|
||||
except Exception as e:
|
||||
print(f'[nbd] {addr} error: {e}')
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
print('PERC H710 recovery NBD server v3 (newstyle protocol)')
|
||||
print(f' device : {DEV}')
|
||||
print(f' lv start : byte {LV_PHYS_START}')
|
||||
print(f' virtual sz : {VIRT_SIZE // (1024**3):.1f} GB')
|
||||
print(f' features : chunk-skip + sb-patch + gdt-synth + newstyle')
|
||||
print()
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(('127.0.0.1', 10809))
|
||||
srv.listen(5)
|
||||
print('Listening on 127.0.0.1:10809')
|
||||
print()
|
||||
print('Connect with:')
|
||||
print(' nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""')
|
||||
print(' mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root')
|
||||
print()
|
||||
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
threading.Thread(target=handle_client, args=(conn, addr),
|
||||
daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
340
test/nbd_server_v4
Normal file
340
test/nbd_server_v4
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NBD server v4 — fixed newstyle protocol size negotiation.
|
||||
|
||||
Key fix: NBD_OPT_GO info reply must send NBD_INFO_EXPORT (type 0) record
|
||||
with correct format, followed by NBD_REP_ACK. Without this the client
|
||||
connects but reports size=0.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
# ── Physical layout ───────────────────────────────────────────────────────────
|
||||
DEV = '/dev/md0'
|
||||
CHUNK_BYTES = 128 * 512 # 64 KB
|
||||
LV_PHYS_START = 5120000 * 512 # byte 2,621,440,000
|
||||
VIRT_SIZE = 9365766144 * 512 # from superblock block count
|
||||
|
||||
# ── ext4 filesystem parameters ────────────────────────────────────────────────
|
||||
BSIZE = 4096
|
||||
GDT_ENTRY = 64
|
||||
NUM_GROUPS = 35728
|
||||
|
||||
GDT_START_VIRT = BSIZE
|
||||
GDT_END_VIRT = BSIZE + NUM_GROUPS * GDT_ENTRY
|
||||
|
||||
SB_VIRT_OFFSET = 1024
|
||||
SB_SIZE = 1024
|
||||
SB_INCOMPAT_OFF = 96
|
||||
SB_RO_COMPAT_OFF = 100
|
||||
SB_CHECKSUM_OFF = 1020
|
||||
|
||||
INCOMPAT_HAS_JOURNAL = 0x00000004
|
||||
RO_COMPAT_METADATA_CSUM = 0x00000400
|
||||
RO_COMPAT_GDT_CSUM = 0x00000010
|
||||
|
||||
_patched_sb = None
|
||||
_sb_lock = threading.Lock()
|
||||
|
||||
# ── NBD protocol ──────────────────────────────────────────────────────────────
|
||||
NBDMAGIC = 0x4e42444d41474943
|
||||
IHAVEOPT = 0x49484156454F5054
|
||||
REPLYMAGIC = 0x3e889045565a9
|
||||
|
||||
NBD_OPT_EXPORT_NAME = 1
|
||||
NBD_OPT_ABORT = 2
|
||||
NBD_OPT_LIST = 3
|
||||
NBD_OPT_GO = 7
|
||||
|
||||
NBD_REP_ACK = 1
|
||||
NBD_REP_SERVER = 2
|
||||
NBD_REP_INFO = 3
|
||||
NBD_REP_ERR_UNSUP = (1 << 31) | 1
|
||||
|
||||
NBD_INFO_EXPORT = 0 # info type: export size + flags
|
||||
|
||||
NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
NBD_FLAG_READ_ONLY = 1 << 1
|
||||
NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
|
||||
NBD_REQUEST_MAGIC = 0x25609513
|
||||
NBD_REPLY_MAGIC = 0x67446698
|
||||
|
||||
NBD_CMD_READ = 0
|
||||
NBD_CMD_WRITE = 1
|
||||
NBD_CMD_DISC = 2
|
||||
NBD_CMD_FLUSH = 3
|
||||
|
||||
TRANSMISSION_FLAGS = NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY | NBD_FLAG_SEND_FLUSH
|
||||
|
||||
|
||||
# ── Chunk translation ─────────────────────────────────────────────────────────
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open(DEV, 'rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
if chunk_idx != 4:
|
||||
phys = (LV_PHYS_START
|
||||
+ group * 4 * CHUNK_BYTES
|
||||
+ chunk_idx * CHUNK_BYTES
|
||||
+ intra)
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off + len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
|
||||
# ── GDT synthesis ─────────────────────────────────────────────────────────────
|
||||
|
||||
def make_gdt_entry(n):
|
||||
gd = bytearray(64)
|
||||
struct.pack_into('<I', gd, 0, 1038 + n) # block_bitmap_lo
|
||||
struct.pack_into('<I', gd, 4, 1054 + n) # inode_bitmap_lo
|
||||
struct.pack_into('<I', gd, 8, 1070 + n * 512) # inode_table_lo
|
||||
struct.pack_into('<I', gd, 32, 0) # block_bitmap_hi = 0
|
||||
struct.pack_into('<I', gd, 36, 0) # inode_bitmap_hi = 0
|
||||
struct.pack_into('<I', gd, 40, 0) # inode_table_hi = 0
|
||||
# bg_flags: set INODE_UNINIT and BLOCK_UNINIT for damaged groups
|
||||
# This tells ext4 to not validate bitmaps for these groups
|
||||
struct.pack_into('<H', gd, 18, 0x0003) # EXT4_BG_INODE_UNINIT | EXT4_BG_BLOCK_UNINIT
|
||||
return bytes(gd)
|
||||
|
||||
|
||||
def patch_gdt(data, virt_offset, length):
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
seg_end = pos + seg_len
|
||||
if chunk_idx == 4:
|
||||
ol_start = max(pos, GDT_START_VIRT)
|
||||
ol_end = min(seg_end, GDT_END_VIRT)
|
||||
if ol_start < ol_end:
|
||||
for byte_abs in range(ol_start, ol_end):
|
||||
gdt_rel = byte_abs - GDT_START_VIRT
|
||||
grp = gdt_rel // GDT_ENTRY
|
||||
byte_in = gdt_rel % GDT_ENTRY
|
||||
if grp < NUM_GROUPS:
|
||||
entry = make_gdt_entry(grp)
|
||||
data[byte_abs - virt_offset] = entry[byte_in]
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
|
||||
|
||||
# ── Superblock patch ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_patched_sb():
|
||||
global _patched_sb
|
||||
with _sb_lock:
|
||||
if _patched_sb is not None:
|
||||
return _patched_sb
|
||||
sb = bytearray(raw_read(SB_VIRT_OFFSET, SB_SIZE))
|
||||
incompat = struct.unpack_from('<I', sb, SB_INCOMPAT_OFF)[0]
|
||||
ro_compat = struct.unpack_from('<I', sb, SB_RO_COMPAT_OFF)[0]
|
||||
incompat &= ~INCOMPAT_HAS_JOURNAL
|
||||
ro_compat &= ~(RO_COMPAT_METADATA_CSUM | RO_COMPAT_GDT_CSUM)
|
||||
struct.pack_into('<I', sb, SB_INCOMPAT_OFF, incompat)
|
||||
struct.pack_into('<I', sb, SB_RO_COMPAT_OFF, ro_compat)
|
||||
struct.pack_into('<I', sb, SB_CHECKSUM_OFF, 0)
|
||||
# Add to get_patched_sb() in nbd_server_v4.py after the existing patches:
|
||||
|
||||
# Clear checksum type (offset 222, 1 byte)
|
||||
sb[222] = 0
|
||||
|
||||
# Clear checksum seed (offset 408, 4 bytes)
|
||||
struct.pack_into('<I', sb, 408, 0)
|
||||
|
||||
_patched_sb = bytes(sb)
|
||||
print(f'[sb] patched incompat=0x{incompat:08x} ro_compat=0x{ro_compat:08x}')
|
||||
return _patched_sb
|
||||
|
||||
|
||||
# ── Combined read ─────────────────────────────────────────────────────────────
|
||||
|
||||
def read_virtual(virt_offset, length):
|
||||
data = bytearray(raw_read(virt_offset, length))
|
||||
req_end = virt_offset + length
|
||||
|
||||
# Patch superblock
|
||||
sb_e = SB_VIRT_OFFSET + SB_SIZE
|
||||
if virt_offset < sb_e and req_end > SB_VIRT_OFFSET:
|
||||
patched = get_patched_sb()
|
||||
cs = max(virt_offset, SB_VIRT_OFFSET) - virt_offset
|
||||
ce = min(req_end, sb_e) - virt_offset
|
||||
ss = max(virt_offset, SB_VIRT_OFFSET) - SB_VIRT_OFFSET
|
||||
data[cs:ce] = patched[ss:ss + (ce - cs)]
|
||||
|
||||
# Patch GDT
|
||||
if virt_offset < GDT_END_VIRT and req_end > GDT_START_VIRT:
|
||||
patch_gdt(data, virt_offset, length)
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
# ── NBD helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def recv_all(conn, n):
|
||||
buf = b''
|
||||
while len(buf) < n:
|
||||
chunk = conn.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError('disconnected')
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def send_option_reply(conn, opt, reply_type, data=b''):
|
||||
"""Send a structured option reply."""
|
||||
conn.sendall(struct.pack('>Q', REPLYMAGIC))
|
||||
conn.sendall(struct.pack('>I', opt))
|
||||
conn.sendall(struct.pack('>I', reply_type))
|
||||
conn.sendall(struct.pack('>I', len(data)))
|
||||
if data:
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def send_info_export(conn, opt):
|
||||
"""
|
||||
Send NBD_REP_INFO with NBD_INFO_EXPORT record, then NBD_REP_ACK.
|
||||
This is what makes the client know the export size.
|
||||
|
||||
NBD_INFO_EXPORT record layout:
|
||||
uint16 info_type = 0 (NBD_INFO_EXPORT)
|
||||
uint64 export_size
|
||||
uint16 transmission_flags
|
||||
"""
|
||||
info_data = struct.pack('>HQH',
|
||||
NBD_INFO_EXPORT,
|
||||
VIRT_SIZE,
|
||||
TRANSMISSION_FLAGS)
|
||||
send_option_reply(conn, opt, NBD_REP_INFO, info_data)
|
||||
send_option_reply(conn, opt, NBD_REP_ACK)
|
||||
|
||||
|
||||
# ── Client handler ────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_client(conn, addr):
|
||||
print(f'[nbd] connect from {addr}')
|
||||
try:
|
||||
# Server handshake: NBDMAGIC + IHAVEOPT + server flags
|
||||
conn.sendall(struct.pack('>Q', NBDMAGIC))
|
||||
conn.sendall(struct.pack('>Q', IHAVEOPT))
|
||||
# Server flags: FIXED_NEWSTYLE (bit 0) + NO_ZEROES (bit 1)
|
||||
conn.sendall(struct.pack('>H', 0x0003))
|
||||
|
||||
# Client flags (4 bytes)
|
||||
recv_all(conn, 4)
|
||||
|
||||
# Option haggling
|
||||
while True:
|
||||
opt_hdr = recv_all(conn, 16)
|
||||
_, opt, opt_len = struct.unpack('>QII', opt_hdr)
|
||||
opt_data = recv_all(conn, opt_len) if opt_len else b''
|
||||
|
||||
print(f'[nbd] {addr} opt={opt} len={opt_len}')
|
||||
|
||||
if opt == NBD_OPT_EXPORT_NAME:
|
||||
# Old-style: send size + flags + (maybe) padding, no reply magic
|
||||
conn.sendall(struct.pack('>Q', VIRT_SIZE))
|
||||
conn.sendall(struct.pack('>H', TRANSMISSION_FLAGS))
|
||||
# NO_ZEROES flag set so skip 124-byte padding
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_GO:
|
||||
# New-style: send NBD_REP_INFO then NBD_REP_ACK
|
||||
send_info_export(conn, opt)
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_LIST:
|
||||
name = b''
|
||||
send_option_reply(conn, opt, NBD_REP_SERVER,
|
||||
struct.pack('>I', len(name)) + name)
|
||||
send_option_reply(conn, opt, NBD_REP_ACK)
|
||||
|
||||
elif opt == NBD_OPT_ABORT:
|
||||
send_option_reply(conn, opt, NBD_REP_ACK)
|
||||
return
|
||||
|
||||
else:
|
||||
send_option_reply(conn, opt, NBD_REP_ERR_UNSUP)
|
||||
|
||||
print(f'[nbd] {addr} entering transmission, size={VIRT_SIZE}')
|
||||
|
||||
# Transmission phase
|
||||
while True:
|
||||
hdr = recv_all(conn, 28)
|
||||
magic, flags, cmd, handle, offset, length = \
|
||||
struct.unpack('>IHHQQI', hdr)
|
||||
|
||||
if magic != NBD_REQUEST_MAGIC:
|
||||
print(f'[nbd] bad magic 0x{magic:08x}')
|
||||
return
|
||||
|
||||
if cmd == NBD_CMD_READ:
|
||||
try:
|
||||
payload = read_virtual(offset, length)
|
||||
except Exception as e:
|
||||
print(f'[nbd] read error offset={offset} len={length}: {e}')
|
||||
payload = b'\x00' * length
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle))
|
||||
conn.sendall(payload)
|
||||
|
||||
elif cmd == NBD_CMD_FLUSH:
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle))
|
||||
|
||||
elif cmd == NBD_CMD_DISC:
|
||||
print(f'[nbd] {addr} disconnect')
|
||||
return
|
||||
|
||||
else:
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 1, handle))
|
||||
|
||||
except (ConnectionError, BrokenPipeError, ConnectionResetError):
|
||||
print(f'[nbd] {addr} dropped')
|
||||
except Exception as e:
|
||||
print(f'[nbd] {addr} error: {e}')
|
||||
import traceback; traceback.print_exc()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
print('PERC H710 recovery NBD server v4')
|
||||
print(f' device : {DEV}')
|
||||
print(f' lv start : byte {LV_PHYS_START}')
|
||||
print(f' virt size : {VIRT_SIZE} bytes ({VIRT_SIZE//1024//1024//1024} GB)')
|
||||
print()
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(('127.0.0.1', 10809))
|
||||
srv.listen(5)
|
||||
print('Listening on 127.0.0.1:10809')
|
||||
print(' nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""')
|
||||
print(' mount -t ext4 -o ro,norecovery /dev/nbd0 /mnt/root')
|
||||
print()
|
||||
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
threading.Thread(target=handle_client, args=(conn, addr),
|
||||
daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
212
test/nbd_server_v5.py
Normal file
212
test/nbd_server_v5.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NBD server v5 — minimal PERC H710 chunk translation only.
|
||||
|
||||
Simply reproduces what the PERC controller presented to the OS:
|
||||
- Skip every 5th 64KB chunk (PERC internal metadata)
|
||||
- Serve the result as a read-only block device
|
||||
|
||||
No superblock patching. No GDT synthesis. The filesystem was written
|
||||
correctly through the PERC's translation — reading it back the same
|
||||
way should give a valid filesystem.
|
||||
|
||||
Usage:
|
||||
python3 nbd_server_v5.py &
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
|
||||
DEV = '/dev/md0'
|
||||
CHUNK_BYTES = 128 * 512 # 64 KB per chunk
|
||||
LV_PHYS_START = 5120000 * 512 # byte 2,621,440,000
|
||||
VIRT_SIZE = 9365766144 * 512 # from superblock block count
|
||||
|
||||
# NBD newstyle protocol constants
|
||||
NBDMAGIC = 0x4e42444d41474943
|
||||
IHAVEOPT = 0x49484156454F5054
|
||||
REPLYMAGIC = 0x3e889045565a9
|
||||
NBD_OPT_EXPORT_NAME = 1
|
||||
NBD_OPT_ABORT = 2
|
||||
NBD_OPT_LIST = 3
|
||||
NBD_OPT_GO = 7
|
||||
NBD_REP_ACK = 1
|
||||
NBD_REP_SERVER = 2
|
||||
NBD_REP_INFO = 3
|
||||
NBD_REP_ERR_UNSUP = (1 << 31) | 1
|
||||
NBD_INFO_EXPORT = 0
|
||||
NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
NBD_FLAG_READ_ONLY = 1 << 1
|
||||
NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
NBD_REQUEST_MAGIC = 0x25609513
|
||||
NBD_REPLY_MAGIC = 0x67446698
|
||||
NBD_CMD_READ = 0
|
||||
NBD_CMD_DISC = 2
|
||||
NBD_CMD_FLUSH = 3
|
||||
|
||||
TX_FLAGS = NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY | NBD_FLAG_SEND_FLUSH
|
||||
|
||||
|
||||
def read_virtual(virt_offset, length):
|
||||
"""
|
||||
Read length bytes from the virtual address space.
|
||||
Applies PERC chunk translation: every 5th 64KB chunk is skipped
|
||||
(it contained PERC internal metadata and is not part of user data).
|
||||
Skipped chunks return zeros.
|
||||
"""
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
|
||||
with open(DEV, 'rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
|
||||
if chunk_idx != 4:
|
||||
phys = (LV_PHYS_START
|
||||
+ group * 4 * CHUNK_BYTES
|
||||
+ chunk_idx * CHUNK_BYTES
|
||||
+ intra)
|
||||
f.seek(phys)
|
||||
chunk = f.read(seg_len)
|
||||
result[dst_off:dst_off + len(chunk)] = chunk
|
||||
# chunk_idx == 4: leave as zeros (PERC metadata, not user data)
|
||||
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def recv_all(conn, n):
|
||||
buf = b''
|
||||
while len(buf) < n:
|
||||
data = conn.recv(n - len(buf))
|
||||
if not data:
|
||||
raise ConnectionError('client disconnected')
|
||||
buf += data
|
||||
return buf
|
||||
|
||||
|
||||
def send_reply(conn, opt, rtype, data=b''):
|
||||
conn.sendall(struct.pack('>Q', REPLYMAGIC))
|
||||
conn.sendall(struct.pack('>I', opt))
|
||||
conn.sendall(struct.pack('>I', rtype))
|
||||
conn.sendall(struct.pack('>I', len(data)))
|
||||
if data:
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def handle_client(conn, addr):
|
||||
print(f'[nbd] {addr} connected')
|
||||
try:
|
||||
# Handshake
|
||||
conn.sendall(struct.pack('>Q', NBDMAGIC))
|
||||
conn.sendall(struct.pack('>Q', IHAVEOPT))
|
||||
conn.sendall(struct.pack('>H', 0x0003)) # FIXED_NEWSTYLE | NO_ZEROES
|
||||
recv_all(conn, 4) # client flags
|
||||
|
||||
# Option haggling
|
||||
while True:
|
||||
hdr = recv_all(conn, 16)
|
||||
_, opt, opt_len = struct.unpack('>QII', hdr)
|
||||
opt_data = recv_all(conn, opt_len) if opt_len else b''
|
||||
|
||||
if opt == NBD_OPT_EXPORT_NAME:
|
||||
conn.sendall(struct.pack('>Q', VIRT_SIZE))
|
||||
conn.sendall(struct.pack('>H', TX_FLAGS))
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_GO:
|
||||
info = struct.pack('>HQH', NBD_INFO_EXPORT, VIRT_SIZE, TX_FLAGS)
|
||||
send_reply(conn, opt, NBD_REP_INFO, info)
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_LIST:
|
||||
name = b''
|
||||
send_reply(conn, opt, NBD_REP_SERVER,
|
||||
struct.pack('>I', 0) + name)
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
|
||||
elif opt == NBD_OPT_ABORT:
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
return
|
||||
|
||||
else:
|
||||
send_reply(conn, opt, NBD_REP_ERR_UNSUP)
|
||||
|
||||
print(f'[nbd] {addr} transmission phase ({VIRT_SIZE//1024//1024//1024}GB)')
|
||||
|
||||
# Transmission
|
||||
while True:
|
||||
hdr = recv_all(conn, 28)
|
||||
magic, flags, cmd, handle, offset, length = \
|
||||
struct.unpack('>IHHQQI', hdr)
|
||||
|
||||
if magic != NBD_REQUEST_MAGIC:
|
||||
print(f'[nbd] bad magic 0x{magic:08x}')
|
||||
return
|
||||
|
||||
if cmd == NBD_CMD_READ:
|
||||
try:
|
||||
payload = read_virtual(offset, length)
|
||||
except Exception as e:
|
||||
print(f'[nbd] read error @ {offset}+{length}: {e}')
|
||||
payload = b'\x00' * length
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle))
|
||||
conn.sendall(payload)
|
||||
|
||||
elif cmd == NBD_CMD_FLUSH:
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle))
|
||||
|
||||
elif cmd == NBD_CMD_DISC:
|
||||
print(f'[nbd] {addr} disconnect')
|
||||
return
|
||||
|
||||
else:
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 1, handle))
|
||||
|
||||
except (ConnectionError, BrokenPipeError, ConnectionResetError):
|
||||
print(f'[nbd] {addr} dropped')
|
||||
except Exception as e:
|
||||
print(f'[nbd] {addr} error: {e}')
|
||||
import traceback; traceback.print_exc()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
print(f'PERC H710 recovery NBD server v5 (minimal)')
|
||||
print(f' device : {DEV}')
|
||||
print(f' lv_start : byte {LV_PHYS_START} (sector {LV_PHYS_START//512})')
|
||||
print(f' virt_size : {VIRT_SIZE//1024//1024//1024} GB')
|
||||
print(f' chunk : {CHUNK_BYTES//1024} KB, every 5th skipped')
|
||||
print()
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(('127.0.0.1', 10809))
|
||||
srv.listen(5)
|
||||
|
||||
print('Listening on 127.0.0.1:10809')
|
||||
print(' nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""')
|
||||
print(' mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root')
|
||||
print()
|
||||
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
threading.Thread(target=handle_client, args=(conn, addr),
|
||||
daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
271
test/nbd_server_v6.py
Normal file
271
test/nbd_server_v6.py
Normal file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NBD server v6 — chunk translation + backup GDT overlay.
|
||||
|
||||
The primary GDT has invalid checksums (written through PERC which stored
|
||||
its own checksums). The backup GDT at block group 1 has valid checksums.
|
||||
We serve the backup GDT bytes at the primary GDT location so the kernel
|
||||
can validate and mount the filesystem.
|
||||
|
||||
Reads the backup GDT once at startup and caches it.
|
||||
All other reads: pure chunk translation, no modification.
|
||||
|
||||
Usage:
|
||||
python3 nbd_server_v6.py &
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import sys
|
||||
|
||||
DEV = '/dev/md0'
|
||||
CHUNK_BYTES = 128 * 512 # 64 KB
|
||||
LV_PHYS_START = 5120000 * 512 # byte 2,621,440,000
|
||||
VIRT_SIZE = 9365766144 * 512 # from superblock
|
||||
|
||||
BSIZE = 4096
|
||||
GDT_ENTRY = 64
|
||||
BPG = 32768
|
||||
NUM_GROUPS = 35728
|
||||
|
||||
# Primary GDT: virtual bytes 4096 to 4096+NUM_GROUPS*64
|
||||
PRIMARY_GDT_START = BSIZE
|
||||
PRIMARY_GDT_SIZE = NUM_GROUPS * GDT_ENTRY
|
||||
PRIMARY_GDT_END = PRIMARY_GDT_START + PRIMARY_GDT_SIZE
|
||||
|
||||
# Backup GDT: at block group 1, block 1 = (BPG+1)*BSIZE
|
||||
BACKUP_GDT_START = (BPG + 1) * BSIZE
|
||||
|
||||
# Cached backup GDT (loaded at startup)
|
||||
_backup_gdt = None
|
||||
|
||||
# NBD protocol
|
||||
NBDMAGIC = 0x4e42444d41474943
|
||||
IHAVEOPT = 0x49484156454F5054
|
||||
REPLYMAGIC = 0x3e889045565a9
|
||||
NBD_OPT_EXPORT_NAME = 1
|
||||
NBD_OPT_ABORT = 2
|
||||
NBD_OPT_LIST = 3
|
||||
NBD_OPT_GO = 7
|
||||
NBD_REP_ACK = 1
|
||||
NBD_REP_SERVER = 2
|
||||
NBD_REP_INFO = 3
|
||||
NBD_REP_ERR_UNSUP = (1 << 31) | 1
|
||||
NBD_INFO_EXPORT = 0
|
||||
NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
NBD_FLAG_READ_ONLY = 1 << 1
|
||||
NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
NBD_REQUEST_MAGIC = 0x25609513
|
||||
NBD_REPLY_MAGIC = 0x67446698
|
||||
NBD_CMD_READ = 0
|
||||
NBD_CMD_DISC = 2
|
||||
NBD_CMD_FLUSH = 3
|
||||
|
||||
TX_FLAGS = NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY | NBD_FLAG_SEND_FLUSH
|
||||
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
"""Pure chunk translation — no modifications."""
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open(DEV, 'rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
if chunk_idx != 4:
|
||||
phys = (LV_PHYS_START
|
||||
+ group * 4 * CHUNK_BYTES
|
||||
+ chunk_idx * CHUNK_BYTES
|
||||
+ intra)
|
||||
f.seek(phys)
|
||||
chunk = f.read(seg_len)
|
||||
result[dst_off:dst_off + len(chunk)] = chunk
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def load_backup_gdt():
|
||||
"""Read and cache the backup GDT from group 1."""
|
||||
global _backup_gdt
|
||||
print(f'[gdt] loading backup GDT from virtual byte {BACKUP_GDT_START}...')
|
||||
_backup_gdt = raw_read(BACKUP_GDT_START, PRIMARY_GDT_SIZE)
|
||||
|
||||
# Verify first few entries look sane
|
||||
ok = True
|
||||
for i in range(min(5, NUM_GROUPS)):
|
||||
e = _backup_gdt[i*GDT_ENTRY:(i+1)*GDT_ENTRY]
|
||||
bb = struct.unpack_from('<I', e, 0)[0]
|
||||
ib = struct.unpack_from('<I', e, 4)[0]
|
||||
it = struct.unpack_from('<I', e, 8)[0]
|
||||
cs = struct.unpack_from('<H', e, 30)[0]
|
||||
print(f'[gdt] group {i}: bb={bb} ib={ib} it={it} csum=0x{cs:04x}')
|
||||
if bb == 0 and ib == 0 and it == 0:
|
||||
ok = False
|
||||
|
||||
if not ok:
|
||||
print('[gdt] WARNING: backup GDT looks empty, check parameters')
|
||||
else:
|
||||
print(f'[gdt] backup GDT loaded OK ({PRIMARY_GDT_SIZE//1024}KB)')
|
||||
return ok
|
||||
|
||||
|
||||
def read_virtual(virt_offset, length):
|
||||
"""
|
||||
Read with backup GDT overlay.
|
||||
Primary GDT region (virtual bytes 4096..4096+NUM_GROUPS*64) is
|
||||
served from the cached backup GDT instead of the primary location.
|
||||
Everything else is pure chunk translation.
|
||||
"""
|
||||
req_end = virt_offset + length
|
||||
|
||||
# Fast path: no overlap with primary GDT
|
||||
if req_end <= PRIMARY_GDT_START or virt_offset >= PRIMARY_GDT_END:
|
||||
return raw_read(virt_offset, length)
|
||||
|
||||
# Build result from possibly multiple segments
|
||||
data = bytearray(raw_read(virt_offset, length))
|
||||
|
||||
# Overlay backup GDT where request overlaps primary GDT
|
||||
ol_start = max(virt_offset, PRIMARY_GDT_START)
|
||||
ol_end = min(req_end, PRIMARY_GDT_END)
|
||||
if ol_start < ol_end and _backup_gdt is not None:
|
||||
src_off = ol_start - PRIMARY_GDT_START
|
||||
dst_off = ol_start - virt_offset
|
||||
n = ol_end - ol_start
|
||||
data[dst_off:dst_off + n] = _backup_gdt[src_off:src_off + n]
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def recv_all(conn, n):
|
||||
buf = b''
|
||||
while len(buf) < n:
|
||||
d = conn.recv(n - len(buf))
|
||||
if not d:
|
||||
raise ConnectionError('disconnected')
|
||||
buf += d
|
||||
return buf
|
||||
|
||||
|
||||
def send_reply(conn, opt, rtype, data=b''):
|
||||
conn.sendall(struct.pack('>Q', REPLYMAGIC))
|
||||
conn.sendall(struct.pack('>I', opt))
|
||||
conn.sendall(struct.pack('>I', rtype))
|
||||
conn.sendall(struct.pack('>I', len(data)))
|
||||
if data:
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def handle_client(conn, addr):
|
||||
print(f'[nbd] {addr} connected')
|
||||
try:
|
||||
conn.sendall(struct.pack('>Q', NBDMAGIC))
|
||||
conn.sendall(struct.pack('>Q', IHAVEOPT))
|
||||
conn.sendall(struct.pack('>H', 0x0003))
|
||||
recv_all(conn, 4)
|
||||
|
||||
while True:
|
||||
hdr = recv_all(conn, 16)
|
||||
_, opt, opt_len = struct.unpack('>QII', hdr)
|
||||
opt_data = recv_all(conn, opt_len) if opt_len else b''
|
||||
|
||||
if opt == NBD_OPT_EXPORT_NAME:
|
||||
conn.sendall(struct.pack('>Q', VIRT_SIZE))
|
||||
conn.sendall(struct.pack('>H', TX_FLAGS))
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_GO:
|
||||
info = struct.pack('>HQH', NBD_INFO_EXPORT, VIRT_SIZE, TX_FLAGS)
|
||||
send_reply(conn, opt, NBD_REP_INFO, info)
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
break
|
||||
|
||||
elif opt == NBD_OPT_LIST:
|
||||
send_reply(conn, opt, NBD_REP_SERVER,
|
||||
struct.pack('>I', 0))
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
|
||||
elif opt == NBD_OPT_ABORT:
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
return
|
||||
|
||||
else:
|
||||
send_reply(conn, opt, NBD_REP_ERR_UNSUP)
|
||||
|
||||
print(f'[nbd] {addr} transmission ({VIRT_SIZE//1024//1024//1024}GB)')
|
||||
|
||||
while True:
|
||||
hdr = recv_all(conn, 28)
|
||||
magic, flags, cmd, handle, offset, length = \
|
||||
struct.unpack('>IHHQQI', hdr)
|
||||
|
||||
if magic != NBD_REQUEST_MAGIC:
|
||||
return
|
||||
|
||||
if cmd == NBD_CMD_READ:
|
||||
try:
|
||||
payload = read_virtual(offset, length)
|
||||
except Exception as e:
|
||||
print(f'[nbd] read error @ {offset}+{length}: {e}')
|
||||
payload = b'\x00' * length
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle))
|
||||
conn.sendall(payload)
|
||||
|
||||
elif cmd == NBD_CMD_FLUSH:
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle))
|
||||
|
||||
elif cmd == NBD_CMD_DISC:
|
||||
return
|
||||
|
||||
else:
|
||||
conn.sendall(struct.pack('>IIQ', NBD_REPLY_MAGIC, 1, handle))
|
||||
|
||||
except (ConnectionError, BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f'[nbd] {addr} error: {e}')
|
||||
import traceback; traceback.print_exc()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
print('PERC H710 recovery NBD server v6')
|
||||
print(f' device : {DEV}')
|
||||
print(f' lv_start : byte {LV_PHYS_START}')
|
||||
print(f' virt_size : {VIRT_SIZE//1024//1024//1024} GB')
|
||||
print(f' primary GDT: virtual bytes {PRIMARY_GDT_START}-{PRIMARY_GDT_END}')
|
||||
print(f' backup GDT : virtual byte {BACKUP_GDT_START}')
|
||||
print()
|
||||
|
||||
if not load_backup_gdt():
|
||||
print('ERROR: backup GDT load failed, check BACKUP_GDT_START')
|
||||
sys.exit(1)
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(('127.0.0.1', 10809))
|
||||
srv.listen(5)
|
||||
print()
|
||||
print('Listening on 127.0.0.1:10809')
|
||||
print(' nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""')
|
||||
print(' mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root')
|
||||
print()
|
||||
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
threading.Thread(target=handle_client, args=(conn, addr),
|
||||
daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
263
test/nbd_server_v8.py
Normal file
263
test/nbd_server_v8.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NBD server v8 - PERC H710 chunk translation + on-the-fly patches:
|
||||
1. Superblock: clear metadata_csum, gdt_csum, has_journal feature bits
|
||||
2. GDT: zero all checksum fields in every group descriptor
|
||||
|
||||
Nothing is written to disk. All patches applied in memory on reads.
|
||||
"""
|
||||
import socket, struct, threading
|
||||
|
||||
DEV = '/dev/md0'
|
||||
CHUNK_BYTES = 128 * 512
|
||||
LV_PHYS_START = 5120000 * 512
|
||||
VIRT_SIZE = 9365766144 * 512
|
||||
|
||||
# Superblock location and fields
|
||||
SB_OFFSET = 1024
|
||||
SB_SIZE = 1024
|
||||
SB_COMPAT = 92
|
||||
SB_INCOMPAT = 96
|
||||
SB_RO_COMPAT = 100
|
||||
SB_JNLINUM = 180
|
||||
SB_CHECKSUM = 1020
|
||||
INCOMPAT_HAS_JOURNAL = 0x004
|
||||
COMPAT_HAS_JOURNAL = 0x004
|
||||
RO_COMPAT_GDT_CSUM = 0x010
|
||||
RO_COMPAT_METADATA_CSUM = 0x400
|
||||
|
||||
# GDT location and fields
|
||||
GDT_OFFSET = 4096 # block 1
|
||||
GDT_ENTRY_SZ = 64
|
||||
NUM_GROUPS = 35728
|
||||
GDT_SIZE = NUM_GROUPS * GDT_ENTRY_SZ
|
||||
GDT_END = GDT_OFFSET + GDT_SIZE
|
||||
GDT_CSUM_OFF = 30 # checksum offset within each entry
|
||||
|
||||
# NBD protocol
|
||||
NBDMAGIC = 0x4e42444d41474943
|
||||
IHAVEOPT = 0x49484156454F5054
|
||||
REPLYMAGIC = 0x3e889045565a9
|
||||
NBD_OPT_EXPORT_NAME = 1
|
||||
NBD_OPT_ABORT = 2
|
||||
NBD_OPT_LIST = 3
|
||||
NBD_OPT_GO = 7
|
||||
NBD_REP_ACK = 1
|
||||
NBD_REP_SERVER = 2
|
||||
NBD_REP_INFO = 3
|
||||
NBD_REP_ERR_UNSUP = (1 << 31) | 1
|
||||
NBD_INFO_EXPORT = 0
|
||||
NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
NBD_FLAG_READ_ONLY = 1 << 1
|
||||
NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
NBD_REQUEST_MAGIC = 0x25609513
|
||||
NBD_REPLY_MAGIC = 0x67446698
|
||||
NBD_CMD_READ = 0
|
||||
NBD_CMD_DISC = 2
|
||||
NBD_CMD_FLUSH = 3
|
||||
TX_FLAGS = NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY | NBD_FLAG_SEND_FLUSH
|
||||
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
"""Pure chunk translation, no patching."""
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open(DEV, 'rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
if chunk_idx != 4:
|
||||
phys = (LV_PHYS_START
|
||||
+ group * 4 * CHUNK_BYTES
|
||||
+ chunk_idx * CHUNK_BYTES
|
||||
+ intra)
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off + len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return result
|
||||
|
||||
|
||||
def patch_superblock(data, req_start):
|
||||
"""
|
||||
Patch superblock feature bits in a data buffer.
|
||||
req_start: virtual offset of data[0].
|
||||
"""
|
||||
sb_start = SB_OFFSET
|
||||
sb_end = SB_OFFSET + SB_SIZE
|
||||
req_end = req_start + len(data)
|
||||
|
||||
if req_start >= sb_end or req_end <= sb_start:
|
||||
return # no overlap
|
||||
|
||||
# Offsets into data buffer
|
||||
def patch_u32(sb_field_off, mask_clear):
|
||||
buf_off = sb_start + sb_field_off - req_start
|
||||
if 0 <= buf_off <= len(data) - 4:
|
||||
val = struct.unpack_from('<I', data, buf_off)[0]
|
||||
val &= ~mask_clear
|
||||
struct.pack_into('<I', data, buf_off, val)
|
||||
|
||||
def zero_u32(sb_field_off):
|
||||
buf_off = sb_start + sb_field_off - req_start
|
||||
if 0 <= buf_off <= len(data) - 4:
|
||||
struct.pack_into('<I', data, buf_off, 0)
|
||||
|
||||
patch_u32(SB_COMPAT, COMPAT_HAS_JOURNAL)
|
||||
patch_u32(SB_INCOMPAT, INCOMPAT_HAS_JOURNAL)
|
||||
patch_u32(SB_RO_COMPAT, RO_COMPAT_GDT_CSUM | RO_COMPAT_METADATA_CSUM)
|
||||
zero_u32(SB_JNLINUM)
|
||||
zero_u32(SB_CHECKSUM)
|
||||
|
||||
|
||||
def patch_gdt(data, req_start):
|
||||
"""
|
||||
Zero checksum field (offset 30) in every GDT entry that
|
||||
overlaps with this read buffer.
|
||||
req_start: virtual offset of data[0].
|
||||
"""
|
||||
req_end = req_start + len(data)
|
||||
|
||||
if req_start >= GDT_END or req_end <= GDT_OFFSET:
|
||||
return # no overlap
|
||||
|
||||
# First and last GDT entry indices that could overlap
|
||||
first_entry = max(0, (req_start - GDT_OFFSET) // GDT_ENTRY_SZ)
|
||||
last_entry = min(NUM_GROUPS - 1,
|
||||
(req_end - GDT_OFFSET - 1) // GDT_ENTRY_SZ)
|
||||
|
||||
for g in range(first_entry, last_entry + 1):
|
||||
# Virtual offset of checksum field for group g
|
||||
csum_virt = GDT_OFFSET + g * GDT_ENTRY_SZ + GDT_CSUM_OFF
|
||||
buf_off = csum_virt - req_start
|
||||
if 0 <= buf_off <= len(data) - 2:
|
||||
struct.pack_into('<H', data, buf_off, 0)
|
||||
|
||||
|
||||
def read_virtual(virt_offset, length):
|
||||
data = raw_read(virt_offset, length)
|
||||
patch_superblock(data, virt_offset)
|
||||
patch_gdt(data, virt_offset)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def recv_all(conn, n):
|
||||
buf = b''
|
||||
while len(buf) < n:
|
||||
d = conn.recv(n - len(buf))
|
||||
if not d:
|
||||
raise ConnectionError('disconnected')
|
||||
buf += d
|
||||
return buf
|
||||
|
||||
|
||||
def send_reply(conn, opt, rtype, data=b''):
|
||||
conn.sendall(struct.pack('>Q', REPLYMAGIC))
|
||||
conn.sendall(struct.pack('>I', opt))
|
||||
conn.sendall(struct.pack('>I', rtype))
|
||||
conn.sendall(struct.pack('>I', len(data)))
|
||||
if data:
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def handle_client(conn, addr):
|
||||
print(f'[nbd] {addr} connected')
|
||||
try:
|
||||
conn.sendall(struct.pack('>Q', NBDMAGIC))
|
||||
conn.sendall(struct.pack('>Q', IHAVEOPT))
|
||||
conn.sendall(struct.pack('>H', 0x0003))
|
||||
recv_all(conn, 4)
|
||||
|
||||
while True:
|
||||
hdr = recv_all(conn, 16)
|
||||
_, opt, opt_len = struct.unpack('>QII', hdr)
|
||||
opt_data = recv_all(conn, opt_len) if opt_len else b''
|
||||
|
||||
if opt == NBD_OPT_EXPORT_NAME:
|
||||
conn.sendall(struct.pack('>Q', VIRT_SIZE))
|
||||
conn.sendall(struct.pack('>H', TX_FLAGS))
|
||||
break
|
||||
elif opt == NBD_OPT_GO:
|
||||
info = struct.pack('>HQH', NBD_INFO_EXPORT,
|
||||
VIRT_SIZE, TX_FLAGS)
|
||||
send_reply(conn, opt, NBD_REP_INFO, info)
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
break
|
||||
elif opt == NBD_OPT_LIST:
|
||||
send_reply(conn, opt, NBD_REP_SERVER,
|
||||
struct.pack('>I', 0))
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
elif opt == NBD_OPT_ABORT:
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
return
|
||||
else:
|
||||
send_reply(conn, opt, NBD_REP_ERR_UNSUP)
|
||||
|
||||
print(f'[nbd] {addr} in transmission')
|
||||
while True:
|
||||
hdr = recv_all(conn, 28)
|
||||
magic, flags, cmd, handle, offset, length = \
|
||||
struct.unpack('>IHHQQI', hdr)
|
||||
if magic != NBD_REQUEST_MAGIC:
|
||||
return
|
||||
|
||||
if cmd == NBD_CMD_READ:
|
||||
try:
|
||||
payload = read_virtual(offset, length)
|
||||
except Exception as e:
|
||||
print(f'[nbd] read error {offset}+{length}: {e}')
|
||||
payload = b'\x00' * length
|
||||
conn.sendall(struct.pack('>IIQ',
|
||||
NBD_REPLY_MAGIC, 0, handle))
|
||||
conn.sendall(payload)
|
||||
elif cmd == NBD_CMD_FLUSH:
|
||||
conn.sendall(struct.pack('>IIQ',
|
||||
NBD_REPLY_MAGIC, 0, handle))
|
||||
elif cmd == NBD_CMD_DISC:
|
||||
print(f'[nbd] {addr} disconnected')
|
||||
return
|
||||
else:
|
||||
conn.sendall(struct.pack('>IIQ',
|
||||
NBD_REPLY_MAGIC, 1, handle))
|
||||
|
||||
except (ConnectionError, BrokenPipeError, ConnectionResetError):
|
||||
print(f'[nbd] {addr} dropped')
|
||||
except Exception as e:
|
||||
print(f'[nbd] {addr} error: {e}')
|
||||
import traceback; traceback.print_exc()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
print('PERC H710 recovery NBD server v8')
|
||||
print(f' device : {DEV}')
|
||||
print(f' lv_start : byte {LV_PHYS_START}')
|
||||
print(f' virt_size : {VIRT_SIZE // 1024**3} GB')
|
||||
print(f' patches : superblock features + GDT checksums (on-the-fly)')
|
||||
print()
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(('127.0.0.1', 10809))
|
||||
srv.listen(5)
|
||||
print('Listening on 127.0.0.1:10809')
|
||||
print(' nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""')
|
||||
print(' fls /dev/nbd0 1585918')
|
||||
print(' mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root')
|
||||
print()
|
||||
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
threading.Thread(target=handle_client, args=(conn, addr),
|
||||
daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
247
test/nbd_server_v9.py
Normal file
247
test/nbd_server_v9.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NBD server v9 - PERC H710 chunk translation + merged GDT overlay only.
|
||||
|
||||
No feature flag patching. No checksum zeroing. No journal disabling.
|
||||
Just serves the filesystem exactly as it was written, with a complete
|
||||
GDT built by merging primary (for non-metadata-chunk entries) and
|
||||
backup group 1 (for entries that fall in metadata chunks).
|
||||
|
||||
Primary and backup GDT bad entries are completely disjoint (0 overlap),
|
||||
so this gives 100% GDT coverage with authentic data and valid checksums.
|
||||
|
||||
Usage:
|
||||
# First build the merged GDT:
|
||||
# python3 build_merged_gdt.py (saves /tmp/merged_gdt.bin)
|
||||
|
||||
python3 nbd_server_v9.py &
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
ext4magic /dev/nbd0 -s 4096 -n 32768 -R -I 1585918 -a $(date -d "2023-01-01" +%s) -d /mnt/recovered
|
||||
"""
|
||||
|
||||
import socket, struct, threading, sys, os
|
||||
|
||||
DEV = '/dev/md0'
|
||||
MERGED_GDT = '/tmp/merged_gdt.bin'
|
||||
CHUNK_BYTES = 128 * 512
|
||||
LV_PHYS_START = 5120000 * 512
|
||||
VIRT_SIZE = 9365766144 * 512
|
||||
|
||||
# GDT location in virtual address space
|
||||
BSIZE = 4096
|
||||
GDT_ENTRY_SZ = 64
|
||||
NUM_GROUPS = 35728
|
||||
GDT_VIRT_START = BSIZE # block 1 = byte 4096
|
||||
GDT_VIRT_END = BSIZE + NUM_GROUPS * GDT_ENTRY_SZ
|
||||
|
||||
# NBD protocol
|
||||
NBDMAGIC = 0x4e42444d41474943
|
||||
IHAVEOPT = 0x49484156454F5054
|
||||
REPLYMAGIC = 0x3e889045565a9
|
||||
NBD_OPT_EXPORT_NAME = 1
|
||||
NBD_OPT_ABORT = 2
|
||||
NBD_OPT_LIST = 3
|
||||
NBD_OPT_GO = 7
|
||||
NBD_REP_ACK = 1
|
||||
NBD_REP_SERVER = 2
|
||||
NBD_REP_INFO = 3
|
||||
NBD_REP_ERR_UNSUP = (1 << 31) | 1
|
||||
NBD_INFO_EXPORT = 0
|
||||
NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
NBD_FLAG_READ_ONLY = 1 << 1
|
||||
NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
NBD_REQUEST_MAGIC = 0x25609513
|
||||
NBD_REPLY_MAGIC = 0x67446698
|
||||
NBD_CMD_READ = 0
|
||||
NBD_CMD_DISC = 2
|
||||
NBD_CMD_FLUSH = 3
|
||||
TX_FLAGS = NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY | NBD_FLAG_SEND_FLUSH
|
||||
|
||||
# Load merged GDT at startup
|
||||
print(f'Loading merged GDT from {MERGED_GDT}...')
|
||||
if not os.path.exists(MERGED_GDT):
|
||||
print(f'ERROR: {MERGED_GDT} not found.')
|
||||
print('Run the GDT builder script first.')
|
||||
sys.exit(1)
|
||||
|
||||
with open(MERGED_GDT, 'rb') as f:
|
||||
MERGED_GDT_DATA = f.read()
|
||||
|
||||
expected = NUM_GROUPS * GDT_ENTRY_SZ
|
||||
if len(MERGED_GDT_DATA) != expected:
|
||||
print(f'ERROR: merged GDT is {len(MERGED_GDT_DATA)} bytes, '
|
||||
f'expected {expected}')
|
||||
sys.exit(1)
|
||||
|
||||
print(f'Merged GDT loaded: {len(MERGED_GDT_DATA)//1024}KB '
|
||||
f'({NUM_GROUPS} groups)')
|
||||
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
"""Pure PERC chunk translation, no modifications."""
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open(DEV, 'rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
if chunk_idx != 4:
|
||||
phys = (LV_PHYS_START
|
||||
+ group * 4 * CHUNK_BYTES
|
||||
+ chunk_idx * CHUNK_BYTES
|
||||
+ intra)
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off + len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return result
|
||||
|
||||
|
||||
def read_virtual(virt_offset, length):
|
||||
"""
|
||||
Read with merged GDT overlay.
|
||||
Only the primary GDT region (bytes 4096 to 4096+NUM_GROUPS*64)
|
||||
is modified — replaced with the pre-built merged GDT.
|
||||
Everything else is pure chunk translation, unmodified.
|
||||
"""
|
||||
data = raw_read(virt_offset, length)
|
||||
req_end = virt_offset + length
|
||||
|
||||
# Overlay merged GDT where request overlaps primary GDT region
|
||||
if virt_offset < GDT_VIRT_END and req_end > GDT_VIRT_START:
|
||||
ol_start = max(virt_offset, GDT_VIRT_START)
|
||||
ol_end = min(req_end, GDT_VIRT_END)
|
||||
src_off = ol_start - GDT_VIRT_START
|
||||
dst_off = ol_start - virt_offset
|
||||
n = ol_end - ol_start
|
||||
data[dst_off:dst_off + n] = MERGED_GDT_DATA[src_off:src_off + n]
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def recv_all(conn, n):
|
||||
buf = b''
|
||||
while len(buf) < n:
|
||||
d = conn.recv(n - len(buf))
|
||||
if not d:
|
||||
raise ConnectionError('disconnected')
|
||||
buf += d
|
||||
return buf
|
||||
|
||||
|
||||
def send_reply(conn, opt, rtype, data=b''):
|
||||
conn.sendall(struct.pack('>Q', REPLYMAGIC))
|
||||
conn.sendall(struct.pack('>I', opt))
|
||||
conn.sendall(struct.pack('>I', rtype))
|
||||
conn.sendall(struct.pack('>I', len(data)))
|
||||
if data:
|
||||
conn.sendall(data)
|
||||
|
||||
|
||||
def handle_client(conn, addr):
|
||||
print(f'[nbd] {addr} connected')
|
||||
try:
|
||||
conn.sendall(struct.pack('>Q', NBDMAGIC))
|
||||
conn.sendall(struct.pack('>Q', IHAVEOPT))
|
||||
conn.sendall(struct.pack('>H', 0x0003))
|
||||
recv_all(conn, 4)
|
||||
|
||||
while True:
|
||||
hdr = recv_all(conn, 16)
|
||||
_, opt, opt_len = struct.unpack('>QII', hdr)
|
||||
opt_data = recv_all(conn, opt_len) if opt_len else b''
|
||||
|
||||
if opt == NBD_OPT_EXPORT_NAME:
|
||||
conn.sendall(struct.pack('>Q', VIRT_SIZE))
|
||||
conn.sendall(struct.pack('>H', TX_FLAGS))
|
||||
break
|
||||
elif opt == NBD_OPT_GO:
|
||||
info = struct.pack('>HQH', NBD_INFO_EXPORT,
|
||||
VIRT_SIZE, TX_FLAGS)
|
||||
send_reply(conn, opt, NBD_REP_INFO, info)
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
break
|
||||
elif opt == NBD_OPT_LIST:
|
||||
send_reply(conn, opt, NBD_REP_SERVER,
|
||||
struct.pack('>I', 0))
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
elif opt == NBD_OPT_ABORT:
|
||||
send_reply(conn, opt, NBD_REP_ACK)
|
||||
return
|
||||
else:
|
||||
send_reply(conn, opt, NBD_REP_ERR_UNSUP)
|
||||
|
||||
print(f'[nbd] {addr} transmission')
|
||||
while True:
|
||||
hdr = recv_all(conn, 28)
|
||||
magic, flags, cmd, handle, offset, length = \
|
||||
struct.unpack('>IHHQQI', hdr)
|
||||
if magic != NBD_REQUEST_MAGIC:
|
||||
return
|
||||
|
||||
if cmd == NBD_CMD_READ:
|
||||
try:
|
||||
payload = read_virtual(offset, length)
|
||||
except Exception as e:
|
||||
print(f'[nbd] read error {offset}+{length}: {e}')
|
||||
payload = b'\x00' * length
|
||||
conn.sendall(struct.pack('>IIQ',
|
||||
NBD_REPLY_MAGIC, 0, handle))
|
||||
conn.sendall(payload)
|
||||
elif cmd == NBD_CMD_FLUSH:
|
||||
conn.sendall(struct.pack('>IIQ',
|
||||
NBD_REPLY_MAGIC, 0, handle))
|
||||
elif cmd == NBD_CMD_DISC:
|
||||
print(f'[nbd] {addr} disconnected')
|
||||
return
|
||||
else:
|
||||
conn.sendall(struct.pack('>IIQ',
|
||||
NBD_REPLY_MAGIC, 1, handle))
|
||||
|
||||
except (ConnectionError, BrokenPipeError, ConnectionResetError):
|
||||
print(f'[nbd] {addr} dropped')
|
||||
except Exception as e:
|
||||
print(f'[nbd] {addr} error: {e}')
|
||||
import traceback; traceback.print_exc()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
print('PERC H710 recovery NBD server v9')
|
||||
print(f' device : {DEV}')
|
||||
print(f' lv_start : byte {LV_PHYS_START}')
|
||||
print(f' virt_size : {VIRT_SIZE//1024**3} GB')
|
||||
print(f' GDT region : bytes {GDT_VIRT_START}-{GDT_VIRT_END}')
|
||||
print(f' patch : merged GDT only (primary + backup group 1)')
|
||||
print(f' no patches : superblock, features, checksums all authentic')
|
||||
print()
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(('127.0.0.1', 10809))
|
||||
srv.listen(5)
|
||||
print('Listening on 127.0.0.1:10809')
|
||||
print(' nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""')
|
||||
print()
|
||||
print('Then try:')
|
||||
print(' e2fsck -n /dev/nbd0')
|
||||
print(' fls /dev/nbd0 1585918')
|
||||
print(' ext4magic /dev/nbd0 -s 4096 -n 32768 -R -I 1585918 \\')
|
||||
print(' -a $(date -d "2023-01-01" +%s) -d /mnt/recovered')
|
||||
print()
|
||||
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
threading.Thread(target=handle_client, args=(conn, addr),
|
||||
daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
351
test/nbd_v2.py
Normal file
351
test/nbd_v2.py
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NBD server for PERC H710 RAID recovery.
|
||||
|
||||
Applies three transformations on every read:
|
||||
1. Chunk translation — skips every 5th 64KB chunk (PERC internal metadata)
|
||||
2. Superblock patch — clears metadata_csum and has_journal feature bits
|
||||
so the kernel stops validating checksums we can't fix
|
||||
3. GDT reconstruction — synthesizes correct group descriptor entries for
|
||||
regions that fall inside metadata chunks (zeros)
|
||||
|
||||
Confirmed filesystem parameters (from session forensics):
|
||||
bpg = 32768 blocks per group
|
||||
ipg = 8192 inodes per group
|
||||
inode_size = 256 bytes
|
||||
GDT entry = 64 bytes (64bit feature)
|
||||
num_groups = 35728
|
||||
Group N: bb = 1038+N, ib = 1054+N, it = 1070 + N*512
|
||||
|
||||
Usage:
|
||||
python3 nbd_server_v2.py &
|
||||
nbd-client -g 127.0.0.1 10809 /dev/nbd0
|
||||
mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── Physical layout ───────────────────────────────────────────────────────────
|
||||
DEV = '/dev/md0'
|
||||
CHUNK_BYTES = 128 * 512 # 64 KB
|
||||
LV_PHYS_START = 5120000 * 512 # byte 2,621,440,000
|
||||
VIRT_SIZE = 9365766144 * 512 # from superblock block count
|
||||
|
||||
# ── ext4 filesystem parameters ────────────────────────────────────────────────
|
||||
BSIZE = 4096 # block size in bytes
|
||||
BPG = 32768 # blocks per group
|
||||
IPG = 8192 # inodes per group
|
||||
INODE_SIZE = 256 # inode size in bytes
|
||||
GDT_ENTRY = 64 # group descriptor size (64-bit mode)
|
||||
NUM_GROUPS = 35728 # total number of block groups
|
||||
|
||||
# GDT starts at block 1 = byte 4096 from LV start
|
||||
GDT_START_VIRT = BSIZE # virtual byte offset of GDT
|
||||
GDT_END_VIRT = BSIZE + NUM_GROUPS * GDT_ENTRY # ~9.1 MB
|
||||
|
||||
# Superblock is at virtual byte 1024
|
||||
SB_VIRT_OFFSET = 1024
|
||||
SB_SIZE = 1024
|
||||
|
||||
# Feature flag offsets within superblock
|
||||
SB_FEAT_COMPAT_OFF = 92 # s_feature_compat
|
||||
SB_FEAT_INCOMPAT_OFF = 96 # s_feature_incompat
|
||||
SB_FEAT_RO_COMPAT_OFF = 100 # s_feature_ro_compat
|
||||
SB_CHECKSUM_OFF = 1020 # s_checksum (last 4 bytes of sb)
|
||||
|
||||
# Bits to clear
|
||||
INCOMPAT_HAS_JOURNAL = 0x00000004
|
||||
RO_COMPAT_METADATA_CSUM = 0x00000400
|
||||
RO_COMPAT_GDT_CSUM = 0x00000010
|
||||
|
||||
# Cached patched superblock (built once on first read)
|
||||
_patched_sb = None
|
||||
_sb_lock = threading.Lock()
|
||||
|
||||
# ── NBD protocol constants ────────────────────────────────────────────────────
|
||||
NBD_MAGIC = 0x4e42444d41474943
|
||||
NBD_CLISERV_MAGIC = 0x00420281861253
|
||||
NBD_REQUEST_MAGIC = 0x25609513
|
||||
NBD_REPLY_MAGIC = 0x67446698
|
||||
NBD_CMD_READ = 0
|
||||
NBD_CMD_WRITE = 1
|
||||
NBD_CMD_DISC = 2
|
||||
NBD_FLAG_READ_ONLY = 0x0002
|
||||
NBD_FLAG_HAS_FLAGS = 0x0001
|
||||
|
||||
|
||||
# ── Chunk translation ─────────────────────────────────────────────────────────
|
||||
|
||||
def virt_to_phys_segments(virt_offset, length):
|
||||
"""
|
||||
Yield (phys_offset_or_None, seg_length) pairs covering the request.
|
||||
None means the region falls in a PERC metadata chunk → return zeros.
|
||||
"""
|
||||
remaining = length
|
||||
pos = virt_offset
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
chunk_remain = CHUNK_BYTES - intra
|
||||
seg_len = min(chunk_remain, remaining)
|
||||
|
||||
if chunk_idx == 4:
|
||||
yield None, seg_len
|
||||
else:
|
||||
phys = (LV_PHYS_START
|
||||
+ group * 4 * CHUNK_BYTES
|
||||
+ chunk_idx * CHUNK_BYTES
|
||||
+ intra)
|
||||
yield phys, seg_len
|
||||
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
"""Read virtual bytes without any patching."""
|
||||
result = bytearray(length)
|
||||
written = 0
|
||||
with open(DEV, 'rb') as f:
|
||||
for phys, seg_len in virt_to_phys_segments(virt_offset, length):
|
||||
if phys is not None:
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[written:written + len(data)] = data
|
||||
written += seg_len
|
||||
else:
|
||||
written += seg_len # leave as zeros
|
||||
return bytes(result)
|
||||
|
||||
|
||||
# ── Group descriptor synthesis ────────────────────────────────────────────────
|
||||
|
||||
def make_gdt_entry(group_num):
|
||||
"""
|
||||
Build a 64-byte group descriptor for group N.
|
||||
|
||||
Pattern confirmed from session forensics:
|
||||
block_bitmap_lo = 1038 + N
|
||||
inode_bitmap_lo = 1054 + N
|
||||
inode_table_lo = 1070 + N * 512
|
||||
|
||||
We set free counts to 0 and used_dirs to 0 — the kernel will recompute
|
||||
them if needed. Checksum is left zero (we cleared metadata_csum).
|
||||
"""
|
||||
gd = bytearray(GDT_ENTRY)
|
||||
bb = 1038 + group_num
|
||||
ib = 1054 + group_num
|
||||
it = 1070 + group_num * 512
|
||||
|
||||
struct.pack_into('<I', gd, 0, bb) # bg_block_bitmap_lo
|
||||
struct.pack_into('<I', gd, 4, ib) # bg_inode_bitmap_lo
|
||||
struct.pack_into('<I', gd, 8, it) # bg_inode_table_lo
|
||||
struct.pack_into('<H', gd, 12, 0) # bg_free_blocks_count_lo
|
||||
struct.pack_into('<H', gd, 14, 0) # bg_free_inodes_count_lo
|
||||
struct.pack_into('<H', gd, 16, 0) # bg_used_dirs_count_lo
|
||||
struct.pack_into('<H', gd, 18, 0) # bg_flags
|
||||
struct.pack_into('<H', gd, 30, 0) # bg_checksum (cleared)
|
||||
return bytes(gd)
|
||||
|
||||
|
||||
def synthesize_gdt_region(virt_offset, length):
|
||||
"""
|
||||
Return synthesized GDT bytes for the virtual address range
|
||||
[virt_offset, virt_offset+length) which overlaps the GDT area.
|
||||
"""
|
||||
result = bytearray(length)
|
||||
for i in range(length):
|
||||
abs_byte = virt_offset + i
|
||||
if abs_byte < GDT_START_VIRT or abs_byte >= GDT_END_VIRT:
|
||||
continue
|
||||
gdt_rel = abs_byte - GDT_START_VIRT
|
||||
grp = gdt_rel // GDT_ENTRY
|
||||
byte_in = gdt_rel % GDT_ENTRY
|
||||
if grp < NUM_GROUPS:
|
||||
entry = make_gdt_entry(grp)
|
||||
result[i] = entry[byte_in]
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def overlaps(a_start, a_len, b_start, b_len):
|
||||
return a_start < b_start + b_len and b_start < a_start + a_len
|
||||
|
||||
|
||||
# ── Superblock patching ───────────────────────────────────────────────────────
|
||||
|
||||
def get_patched_superblock():
|
||||
"""
|
||||
Read the real superblock once, clear problematic feature bits, cache it.
|
||||
Clears:
|
||||
- has_journal (skip journal replay)
|
||||
- metadata_csum (stop checksum validation of bitmaps/GDT)
|
||||
- gdt_csum (older checksum variant)
|
||||
"""
|
||||
global _patched_sb
|
||||
with _sb_lock:
|
||||
if _patched_sb is not None:
|
||||
return _patched_sb
|
||||
|
||||
sb = bytearray(raw_read(SB_VIRT_OFFSET, SB_SIZE))
|
||||
|
||||
incompat = struct.unpack_from('<I', sb, SB_FEAT_INCOMPAT_OFF)[0]
|
||||
ro_compat = struct.unpack_from('<I', sb, SB_FEAT_RO_COMPAT_OFF)[0]
|
||||
|
||||
incompat &= ~INCOMPAT_HAS_JOURNAL
|
||||
ro_compat &= ~RO_COMPAT_METADATA_CSUM
|
||||
ro_compat &= ~RO_COMPAT_GDT_CSUM
|
||||
|
||||
struct.pack_into('<I', sb, SB_FEAT_INCOMPAT_OFF, incompat)
|
||||
struct.pack_into('<I', sb, SB_FEAT_RO_COMPAT_OFF, ro_compat)
|
||||
|
||||
# Zero the superblock checksum (last 4 bytes) — no longer valid
|
||||
struct.pack_into('<I', sb, SB_CHECKSUM_OFF, 0)
|
||||
|
||||
_patched_sb = bytes(sb)
|
||||
print(f'[sb] patched incompat=0x{incompat:08x} ro_compat=0x{ro_compat:08x}')
|
||||
return _patched_sb
|
||||
|
||||
|
||||
# ── Main read function ────────────────────────────────────────────────────────
|
||||
|
||||
def read_virtual(virt_offset, length):
|
||||
"""
|
||||
Read `length` bytes from virtual offset `virt_offset`, applying:
|
||||
1. Chunk translation (skip PERC metadata chunks)
|
||||
2. Superblock patching
|
||||
3. GDT synthesis for metadata-chunk regions within the GDT
|
||||
"""
|
||||
# Start with raw translated data
|
||||
data = bytearray(raw_read(virt_offset, length))
|
||||
|
||||
req_end = virt_offset + length
|
||||
|
||||
# ── Patch 1: superblock ───────────────────────────────────────────────────
|
||||
sb_start = SB_VIRT_OFFSET
|
||||
sb_end = SB_VIRT_OFFSET + SB_SIZE
|
||||
if overlaps(virt_offset, length, sb_start, SB_SIZE):
|
||||
patched = get_patched_superblock()
|
||||
copy_start = max(virt_offset, sb_start)
|
||||
copy_end = min(req_end, sb_end)
|
||||
src_off = copy_start - sb_start
|
||||
dst_off = copy_start - virt_offset
|
||||
n = copy_end - copy_start
|
||||
data[dst_off:dst_off + n] = patched[src_off:src_off + n]
|
||||
|
||||
# ── Patch 2: GDT reconstruction ──────────────────────────────────────────
|
||||
# Only needed for regions that overlap the GDT and fall in metadata chunks
|
||||
if overlaps(virt_offset, length, GDT_START_VIRT, GDT_END_VIRT - GDT_START_VIRT):
|
||||
# Walk the metadata chunks within this request to find zero regions
|
||||
# inside the GDT and replace with synthesized data
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
group = pos // (5 * CHUNK_BYTES)
|
||||
in_group = pos % (5 * CHUNK_BYTES)
|
||||
chunk_idx = in_group // CHUNK_BYTES
|
||||
intra = in_group % CHUNK_BYTES
|
||||
seg_len = min(CHUNK_BYTES - intra, remaining)
|
||||
|
||||
if chunk_idx == 4:
|
||||
# This is a metadata chunk — was read as zeros
|
||||
# If it overlaps the GDT, synthesize
|
||||
seg_end = pos + seg_len
|
||||
if overlaps(pos, seg_len, GDT_START_VIRT,
|
||||
GDT_END_VIRT - GDT_START_VIRT):
|
||||
synth = synthesize_gdt_region(pos, seg_len)
|
||||
dst_off = pos - virt_offset
|
||||
data[dst_off:dst_off + seg_len] = synth
|
||||
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
# ── NBD protocol (old-style handshake) ───────────────────────────────────────
|
||||
|
||||
def handle_client(conn, addr):
|
||||
print(f'[nbd] client connected from {addr}')
|
||||
try:
|
||||
# Old-style handshake: magic + cliserv_magic + size + flags + 124 pad
|
||||
conn.sendall(b'NBDMAGIC')
|
||||
conn.sendall(struct.pack('>Q', NBD_CLISERV_MAGIC))
|
||||
conn.sendall(struct.pack('>Q', VIRT_SIZE))
|
||||
conn.sendall(struct.pack('>H', NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY))
|
||||
conn.sendall(b'\x00' * 124)
|
||||
|
||||
print(f'[nbd] handshake done, serving {VIRT_SIZE // (1024**3):.1f} GB')
|
||||
|
||||
while True:
|
||||
hdr = b''
|
||||
while len(hdr) < 28:
|
||||
chunk = conn.recv(28 - len(hdr))
|
||||
if not chunk:
|
||||
return
|
||||
hdr += chunk
|
||||
|
||||
magic, flags, cmd, handle, offset, length = \
|
||||
struct.unpack('>IHHQQI', hdr)
|
||||
|
||||
if magic != NBD_REQUEST_MAGIC:
|
||||
print(f'[nbd] bad magic 0x{magic:08x}, closing')
|
||||
return
|
||||
|
||||
if cmd == NBD_CMD_READ:
|
||||
try:
|
||||
payload = read_virtual(offset, length)
|
||||
except Exception as e:
|
||||
print(f'[nbd] read error offset={offset} len={length}: {e}')
|
||||
payload = b'\x00' * length
|
||||
reply = struct.pack('>IIQ', NBD_REPLY_MAGIC, 0, handle)
|
||||
conn.sendall(reply + payload)
|
||||
|
||||
elif cmd == NBD_CMD_DISC:
|
||||
print(f'[nbd] client disconnected cleanly')
|
||||
return
|
||||
|
||||
else:
|
||||
# Return error for writes and other commands
|
||||
reply = struct.pack('>IIQ', NBD_REPLY_MAGIC, 1, handle)
|
||||
conn.sendall(reply)
|
||||
|
||||
except (ConnectionResetError, BrokenPipeError):
|
||||
print(f'[nbd] client {addr} dropped connection')
|
||||
except Exception as e:
|
||||
print(f'[nbd] error: {e}')
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
print(f'[nbd] PERC H710 recovery NBD server v2')
|
||||
print(f'[nbd] device: {DEV}')
|
||||
print(f'[nbd] lv_start: byte {LV_PHYS_START} (sector {LV_PHYS_START//512})')
|
||||
print(f'[nbd] virt_size: {VIRT_SIZE // (1024**3):.1f} GB')
|
||||
print(f'[nbd] features: chunk-skip + sb-patch + gdt-synth')
|
||||
print()
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(('127.0.0.1', 10809))
|
||||
server.listen(5)
|
||||
print(f'[nbd] listening on 127.0.0.1:10809')
|
||||
print(f'[nbd] connect with: nbd-client -g 127.0.0.1 10809 /dev/nbd0')
|
||||
print(f'[nbd] then mount: mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root')
|
||||
print()
|
||||
|
||||
while True:
|
||||
conn, addr = server.accept()
|
||||
t = threading.Thread(target=handle_client, args=(conn, addr),
|
||||
daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
49802
test/orphaned.txt
Normal file
49802
test/orphaned.txt
Normal file
File diff suppressed because it is too large
Load Diff
12
test/orphaned_detail.txt
Normal file
12
test/orphaned_detail.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
Inode 1585918: mode=0x41c1 size=4096 links=8
|
||||
Directory entries (10):
|
||||
[INTACT] dir inode= 1585918 group= 193 '.'
|
||||
[INTACT] dir inode= 1585910 group= 193 '..'
|
||||
[INTACT] dir inode= 2786054 group= 340 '157a01c7efb651826b6cc4631f37eb1d0d8b0f32a5dd033cc15466545f310218'
|
||||
[INTACT] dir inode= 11697136 group= 1427 '8eae55164cbf9ed92df48106c4ca77dccb5bde05ab79690359df689b7da67a05'
|
||||
[INTACT] dir inode= 2786051 group= 340 '9b91d1f5800648b1611e9398703d2ed6b5c15ba100e21a57e339bb9ce8b9451f'
|
||||
[INTACT] 4 inode= 1572872 group= 192 'backingFsBlockDev'
|
||||
[INTACT] dir inode= 4195616 group= 512 'bracket_bracket_pg_data'
|
||||
[INTACT] dir inode= 6456408 group= 788 'joomla_db_data'
|
||||
[INTACT] dir inode= 6456224 group= 788 'joomla_joomla_data'
|
||||
[INTACT] file inode= 1585920 group= 193 'metadata.db'
|
||||
49842
test/orphaned_inodes.txt
Normal file
49842
test/orphaned_inodes.txt
Normal file
File diff suppressed because one or more lines are too long
24939
test/orphans.txt
Normal file
24939
test/orphans.txt
Normal file
File diff suppressed because one or more lines are too long
38
test/patch.py
Normal file
38
test/patch.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import struct
|
||||
|
||||
with open('/tmp/fs_meta.img', 'r+b') as f:
|
||||
# Patch primary superblock at byte 1024
|
||||
f.seek(1024)
|
||||
sb = bytearray(f.read(1024))
|
||||
|
||||
ro_compat = struct.unpack_from('<I', sb, 100)[0]
|
||||
incompat = struct.unpack_from('<I', sb, 96)[0]
|
||||
print(f'ro_compat before: 0x{ro_compat:08x}')
|
||||
print(f'incompat before: 0x{incompat:08x}')
|
||||
|
||||
ro_compat &= ~0x400 # clear metadata_csum
|
||||
ro_compat &= ~0x010 # clear gdt_csum
|
||||
incompat &= ~0x004 # clear has_journal
|
||||
|
||||
struct.pack_into('<I', sb, 100, ro_compat)
|
||||
struct.pack_into('<I', sb, 96, incompat)
|
||||
struct.pack_into('<I', sb, 1020, 0) # clear sb checksum
|
||||
|
||||
f.seek(1024)
|
||||
f.write(bytes(sb))
|
||||
print(f'ro_compat after: 0x{ro_compat:08x}')
|
||||
print(f'incompat after: 0x{incompat:08x}')
|
||||
|
||||
# Also patch every GDT entry checksum to 0
|
||||
# GDT starts at byte 4096, each entry is 64 bytes
|
||||
# checksum is at offset 30 within each entry
|
||||
NUM_GROUPS = 35728
|
||||
f.seek(4096)
|
||||
gdt = bytearray(f.read(NUM_GROUPS * 64))
|
||||
for g in range(NUM_GROUPS):
|
||||
struct.pack_into('<H', gdt, g*64+30, 0)
|
||||
f.seek(4096)
|
||||
f.write(bytes(gdt))
|
||||
print(f'Zeroed checksums for {NUM_GROUPS} GDT entries')
|
||||
|
||||
print('Done')
|
||||
38
test/patch2.py
Normal file
38
test/patch2.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import struct
|
||||
|
||||
with open('/tmp/fs_meta.img', 'r+b') as f:
|
||||
f.seek(1024)
|
||||
sb = bytearray(f.read(1024))
|
||||
|
||||
ro_compat = struct.unpack_from('<I', sb, 100)[0]
|
||||
incompat = struct.unpack_from('<I', sb, 96)[0]
|
||||
compat = struct.unpack_from('<I', sb, 92)[0]
|
||||
|
||||
print(f'Before: compat=0x{compat:08x} incompat=0x{incompat:08x} ro_compat=0x{ro_compat:08x}')
|
||||
|
||||
incompat &= ~0x004 # clear HAS_JOURNAL
|
||||
ro_compat &= ~0x400 # clear METADATA_CSUM
|
||||
ro_compat &= ~0x010 # clear GDT_CSUM
|
||||
compat &= ~0x004 # clear HAS_JOURNAL from compat too
|
||||
|
||||
# Zero the journal inode number so e2fsck ignores it
|
||||
struct.pack_into('<I', sb, 180, 0) # s_journal_inum = 0
|
||||
|
||||
struct.pack_into('<I', sb, 92, compat)
|
||||
struct.pack_into('<I', sb, 96, incompat)
|
||||
struct.pack_into('<I', sb, 100, ro_compat)
|
||||
struct.pack_into('<I', sb, 1020, 0) # clear sb checksum
|
||||
|
||||
f.seek(1024)
|
||||
f.write(bytes(sb))
|
||||
print(f'After: compat=0x{compat:08x} incompat=0x{incompat:08x} ro_compat=0x{ro_compat:08x}')
|
||||
|
||||
# Zero all GDT checksums
|
||||
with open('/tmp/fs_meta.img', 'r+b') as f:
|
||||
f.seek(4096)
|
||||
gdt = bytearray(f.read(35728 * 64))
|
||||
for g in range(35728):
|
||||
struct.pack_into('<H', gdt, g*64+30, 0)
|
||||
f.seek(4096)
|
||||
f.write(bytes(gdt))
|
||||
print('GDT checksums zeroed')
|
||||
40
test/patch3.sh
Normal file
40
test/patch3.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
python3 -c "
|
||||
import struct
|
||||
|
||||
data = bytearray(open('/tmp/merged_gdt.bin','rb').read())
|
||||
|
||||
# Fix group 0 specifically
|
||||
# It has flags=0x0004 (INODE_ZEROED) but missing INODE_UNINIT
|
||||
# and has non-zero ib_csum=0x9f37 which won't match zeroed bitmap
|
||||
|
||||
g = 0
|
||||
off = 0 # group 0 offset in GDT
|
||||
|
||||
flags = struct.unpack_from('<H', data, off+18)[0]
|
||||
print(f'Group 0 flags before: 0x{flags:04x}')
|
||||
|
||||
# Set INODE_UNINIT (0x0002) so libext2fs skips inode bitmap validation
|
||||
flags |= 0x0002
|
||||
struct.pack_into('<H', data, off+18, flags)
|
||||
|
||||
# Zero the inode bitmap checksum to match actual zeroed data
|
||||
struct.pack_into('<H', data, off+26, 0) # ib_csum_lo
|
||||
struct.pack_into('<H', data, off+50, 0) # ib_csum_hi
|
||||
|
||||
print(f'Group 0 flags after: 0x{flags:04x}')
|
||||
print(f'Group 0 ib_csum now: 0x0000')
|
||||
|
||||
with open('/tmp/merged_gdt.bin','wb') as f:
|
||||
f.write(data)
|
||||
print('Saved')
|
||||
"
|
||||
|
||||
# Restart server
|
||||
pkill -f nbd_server
|
||||
nbd-client -d /dev/nbd0 2>/dev/null
|
||||
sleep 1
|
||||
python3 nbd_server_v9.py &
|
||||
sleep 2
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
|
||||
e2fsck -n /dev/nbd0 2>&1 | head -20
|
||||
41
test/patch4.py
Normal file
41
test/patch4.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import struct, binascii
|
||||
|
||||
# We need to recompute the GDT checksum for group 0
|
||||
# after modifying its flags
|
||||
#
|
||||
# ext4 GDT checksum = crc32c(uuid + group_num_le16 + gdt_entry_with_csum_zeroed)
|
||||
# The checksum seed is stored in the superblock at offset 408
|
||||
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(1024)
|
||||
sb = f.read(1024)
|
||||
|
||||
uuid = sb[104:120]
|
||||
csum_seed = struct.unpack_from('<I', sb, 408)[0]
|
||||
print(f'UUID: {uuid.hex()}')
|
||||
print(f'csum_seed: 0x{csum_seed:08x}')
|
||||
|
||||
# Install crcmod for crc32c
|
||||
import crcmod
|
||||
crc32c_fn = crcmod.predefined.mkCrcFun('crc-32c')
|
||||
|
||||
data = bytearray(open('/tmp/merged_gdt.bin','rb').read())
|
||||
|
||||
def compute_gdt_csum(g, entry):
|
||||
e = bytearray(entry)
|
||||
struct.pack_into('<H', e, 30, 0) # zero checksum field
|
||||
grp_le = struct.pack('<H', g)
|
||||
csum_data = uuid + grp_le + bytes(e)
|
||||
csum = crc32c_fn(csum_data, csum_seed)
|
||||
return csum & 0xFFFF
|
||||
|
||||
# Recompute checksum for group 0 only
|
||||
g = 0
|
||||
entry = data[0:64]
|
||||
csum = compute_gdt_csum(g, entry)
|
||||
struct.pack_into('<H', data, 30, csum)
|
||||
print(f'Group 0 new checksum: 0x{csum:04x}')
|
||||
|
||||
with open('/tmp/merged_gdt.bin','wb') as f:
|
||||
f.write(data)
|
||||
print('Saved')
|
||||
61
test/patch4.sh
Normal file
61
test/patch4.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
python3 -c "
|
||||
import struct, binascii
|
||||
|
||||
# We need to recompute the GDT checksum for group 0
|
||||
# after modifying its flags
|
||||
#
|
||||
# ext4 GDT checksum = crc32c(uuid + group_num_le16 + gdt_entry_with_csum_zeroed)
|
||||
# The checksum seed is stored in the superblock at offset 408
|
||||
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(1024)
|
||||
sb = f.read(1024)
|
||||
|
||||
uuid = sb[104:120]
|
||||
csum_seed = struct.unpack_from('<I', sb, 408)[0]
|
||||
print(f'UUID: {uuid.hex()}')
|
||||
print(f'csum_seed: 0x{csum_seed:08x}')
|
||||
|
||||
# Install crcmod for crc32c
|
||||
import subprocess
|
||||
subprocess.run(['pip','install','crcmod','--break-system-packages','-q'])
|
||||
import crcmod
|
||||
crc32c_fn = crcmod.predefined.mkCrcFun('crc-32c')
|
||||
|
||||
data = bytearray(open('/tmp/merged_gdt.bin','rb').read())
|
||||
|
||||
def compute_gdt_csum(g, entry):
|
||||
e = bytearray(entry)
|
||||
struct.pack_into('<H', e, 30, 0) # zero checksum field
|
||||
grp_le = struct.pack('<H', g)
|
||||
csum_data = uuid + grp_le + bytes(e)
|
||||
csum = crc32c_fn(csum_data, csum_seed)
|
||||
return csum & 0xFFFF
|
||||
|
||||
# Recompute checksum for group 0 only
|
||||
g = 0
|
||||
entry = data[0:64]
|
||||
csum = compute_gdt_csum(g, entry)
|
||||
struct.pack_into('<H', data, 30, csum)
|
||||
print(f'Group 0 new checksum: 0x{csum:04x}')
|
||||
|
||||
with open('/tmp/merged_gdt.bin','wb') as f:
|
||||
f.write(data)
|
||||
print('Saved')
|
||||
"
|
||||
|
||||
# Restart and test
|
||||
pkill -f nbd_server
|
||||
nbd-client -d /dev/nbd0 2>/dev/null
|
||||
sleep 1
|
||||
python3 nbd_server_v9.py &
|
||||
sleep 2
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
|
||||
# Full e2fsck read-only check
|
||||
e2fsck -n /dev/nbd0 2>&1 | tee /tmp/e2fsck_full.log
|
||||
tail -5 /tmp/e2fsck_full.log
|
||||
|
||||
# Try mounting
|
||||
mount -o ro,norecovery -t ext4 /dev/nbd0 /mnt/root
|
||||
ls /mnt/root
|
||||
76
test/patch5.sh
Normal file
76
test/patch5.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
pip install crcmod --break-system-packages -q
|
||||
|
||||
python3 -c "
|
||||
import struct, crcmod
|
||||
|
||||
crc32c = crcmod.predefined.mkCrcFun('crc-32c')
|
||||
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(1024)
|
||||
sb = f.read(1024)
|
||||
|
||||
uuid = sb[104:120]
|
||||
csum_seed = struct.unpack_from('<I', sb, 408)[0]
|
||||
BSIZE = 4096
|
||||
|
||||
def compute_bitmap_csum(bitmap_data):
|
||||
return crc32c(uuid + bitmap_data, csum_seed) & 0xFFFF
|
||||
|
||||
def compute_gdt_csum(g, entry):
|
||||
e = bytearray(entry)
|
||||
struct.pack_into('<H', e, 30, 0)
|
||||
return crc32c(uuid + struct.pack('<H', g) + bytes(e), csum_seed) & 0xFFFF
|
||||
|
||||
data = bytearray(open('/tmp/merged_gdt.bin','rb').read())
|
||||
|
||||
print('Fixing groups 0-12 only (zeroed by fast initialization)...')
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
for g in range(13):
|
||||
e = data[g*64:(g+1)*64]
|
||||
|
||||
# Read actual inode bitmap (will be zeros for damaged groups)
|
||||
ib_block = struct.unpack_from('<I', e, 4)[0]
|
||||
f.seek(ib_block * BSIZE)
|
||||
ib_data = f.read(BSIZE)
|
||||
|
||||
# Read actual block bitmap
|
||||
bb_block = struct.unpack_from('<I', e, 0)[0]
|
||||
f.seek(bb_block * BSIZE)
|
||||
bb_data = f.read(BSIZE)
|
||||
|
||||
# Compute correct checksums from actual bitmap data
|
||||
ib_csum = compute_bitmap_csum(ib_data)
|
||||
bb_csum = compute_bitmap_csum(bb_data)
|
||||
|
||||
struct.pack_into('<H', data, g*64+26, ib_csum) # ib_csum_lo
|
||||
struct.pack_into('<H', data, g*64+50, 0) # ib_csum_hi
|
||||
struct.pack_into('<H', data, g*64+24, bb_csum) # bb_csum_lo
|
||||
struct.pack_into('<H', data, g*64+48, 0) # bb_csum_hi
|
||||
|
||||
# Recompute GDT entry checksum
|
||||
gdt_csum = compute_gdt_csum(g, data[g*64:(g+1)*64])
|
||||
struct.pack_into('<H', data, g*64+30, gdt_csum)
|
||||
|
||||
print(f' Group {g:2d}: ib_csum=0x{ib_csum:04x} '
|
||||
f'bb_csum=0x{bb_csum:04x} gdt_csum=0x{gdt_csum:04x}')
|
||||
|
||||
with open('/tmp/merged_gdt.bin','wb') as f:
|
||||
f.write(data)
|
||||
print('Saved')
|
||||
"
|
||||
|
||||
pkill -f nbd_server
|
||||
nbd-client -d /dev/nbd0 2>/dev/null
|
||||
sleep 1
|
||||
python3 nbd_server_v9.py &
|
||||
sleep 2
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
|
||||
e2fsck -n /dev/nbd0 2>&1 | head -10
|
||||
|
||||
ext4magic /dev/nbd0 \
|
||||
-s 4096 -n 32768 \
|
||||
-M \
|
||||
-a $(date -d "2023-01-01" +%s) \
|
||||
-d /mnt/recovered \
|
||||
2>&1 | tee /tmp/ext4magic.log
|
||||
54
test/patch_gdt.py
Normal file
54
test/patch_gdt.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Patch superblock to disable metadata_csum feature flag,
|
||||
and zero GDT checksum fields for groups 0-12.
|
||||
All writes go to dm-0 (overlay), nbd0 untouched.
|
||||
"""
|
||||
import struct
|
||||
|
||||
DEV = '/dev/dm-0'
|
||||
BLOCK = 4096
|
||||
BACKUP_SB_BLOCK = 32768
|
||||
|
||||
# Feature flag constants
|
||||
INCOMPAT_64BIT = 0x80
|
||||
RO_COMPAT_METADATA_CSUM = 0x400
|
||||
|
||||
with open(DEV, 'r+b') as f:
|
||||
# Read primary superblock (already patched to block 0)
|
||||
f.seek(1024)
|
||||
sb = bytearray(f.read(1024))
|
||||
|
||||
# Check current feature flags
|
||||
ro_compat = struct.unpack_from('<I', sb, 100)[0]
|
||||
print(f"ro_compat_features: {ro_compat:#010x}")
|
||||
print(f"metadata_csum set: {bool(ro_compat & RO_COMPAT_METADATA_CSUM)}")
|
||||
|
||||
# Clear metadata_csum
|
||||
ro_compat &= ~RO_COMPAT_METADATA_CSUM
|
||||
struct.pack_into('<I', sb, 100, ro_compat)
|
||||
|
||||
# Zero superblock checksum
|
||||
struct.pack_into('<I', sb, 1020, 0)
|
||||
|
||||
# Write patched superblock back
|
||||
f.seek(1024)
|
||||
f.write(sb)
|
||||
print("Patched superblock: metadata_csum cleared")
|
||||
|
||||
# Now zero bg_checksum in GDT entries for groups 0-12
|
||||
# bg_checksum is at offset 30 in each 64-byte descriptor
|
||||
f.seek(BLOCK) # GDT starts at block 1
|
||||
gdt = bytearray(f.read(13 * 64)) # groups 0-12 only
|
||||
|
||||
for grp in range(13):
|
||||
off = grp * 64
|
||||
old_csum = struct.unpack_from('<H', gdt, off + 30)[0]
|
||||
struct.pack_into('<H', gdt, off + 30, 0)
|
||||
print(f" group {grp:2d}: cleared checksum {old_csum:#06x}")
|
||||
|
||||
f.seek(BLOCK)
|
||||
f.write(gdt)
|
||||
print("Patched GDT checksums for groups 0-12")
|
||||
|
||||
print("Done - try debugfs again")
|
||||
5
test/rdump_all.sh
Normal file
5
test/rdump_all.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
while read inum rest; do
|
||||
mkdir -p /mnt/recovered/apr29/${inum}
|
||||
python3.12 dump_tree.py ${inum} /mnt/recovered/apr29/${inum}/ &
|
||||
done < true_roots.txt
|
||||
wait
|
||||
306
test/rebuild.py
Normal file
306
test/rebuild.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Full filesystem extraction using TSK ils + fls + icat.
|
||||
|
||||
Strategy:
|
||||
1. ils - get every allocated inode
|
||||
2. For each inode, determine if file or directory
|
||||
3. Build directory tree bottom-up using parent pointers (..)
|
||||
4. Extract everything, place orphans in /orphans/<inode>
|
||||
"""
|
||||
import subprocess, os, sys, struct, collections
|
||||
|
||||
DEVICE = '/dev/nbd0'
|
||||
OUTDIR = '/mnt/recovered'
|
||||
IPG = 8192
|
||||
MIN_GOOD_GROUP = 13 # groups 0-12 are zeroed
|
||||
|
||||
def run(cmd, timeout=600):
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
return r.stdout, r.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return '', 'timeout'
|
||||
except Exception as e:
|
||||
return '', str(e)
|
||||
|
||||
def run_binary(cmd, timeout=600):
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=timeout)
|
||||
return r.stdout
|
||||
except:
|
||||
return b''
|
||||
|
||||
# ── Phase 1: get all allocated inodes via ils ─────────────────────────────────
|
||||
def get_all_inodes():
|
||||
print('Running ils to enumerate all allocated inodes...')
|
||||
print('(This may take 30-60 minutes for a 4.4TB filesystem)')
|
||||
stdout, _ = run(['ils', '-e', DEVICE], timeout=7200)
|
||||
|
||||
inodes = {} # inode -> {'type': 'f'/'d', 'size': n, 'mtime': n}
|
||||
for line in stdout.splitlines():
|
||||
if line.startswith('|') or not line.strip():
|
||||
continue
|
||||
try:
|
||||
# ils -e format:
|
||||
# inode|alloc|uid|gid|mtime|atime|ctime|dtime|mode|nlink|size|...
|
||||
fields = line.split('|')
|
||||
ino = int(fields[0])
|
||||
alloc = fields[1] # 'a' = allocated, 'f' = free
|
||||
mode = int(fields[8]) if len(fields) > 8 else 0
|
||||
size = int(fields[10]) if len(fields) > 10 else 0
|
||||
mtime = int(fields[4]) if len(fields) > 4 else 0
|
||||
|
||||
if alloc != 'a':
|
||||
continue
|
||||
if ino <= 11:
|
||||
continue
|
||||
grp = (ino - 1) // IPG
|
||||
if grp < MIN_GOOD_GROUP:
|
||||
continue
|
||||
|
||||
# Determine type from mode
|
||||
ftype = (mode & 0o170000)
|
||||
if ftype == 0o040000:
|
||||
t = 'd'
|
||||
elif ftype == 0o100000:
|
||||
t = 'f'
|
||||
elif ftype == 0o120000:
|
||||
t = 'l'
|
||||
else:
|
||||
t = 'o' # other
|
||||
|
||||
inodes[ino] = {'type': t, 'size': size, 'mtime': mtime}
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
print(f'Found {len(inodes)} allocated inodes in intact groups')
|
||||
dirs = sum(1 for v in inodes.values() if v['type'] == 'd')
|
||||
files = sum(1 for v in inodes.values() if v['type'] == 'f')
|
||||
print(f' Directories: {dirs}')
|
||||
print(f' Files: {files}')
|
||||
return inodes
|
||||
|
||||
# ── Phase 2: build directory tree using fls ───────────────────────────────────
|
||||
def build_tree(dir_inodes):
|
||||
"""
|
||||
For each directory inode, run fls to get its contents.
|
||||
Build a map of inode -> (parent_inode, name).
|
||||
"""
|
||||
print(f'\nBuilding directory tree from {len(dir_inodes)} directory inodes...')
|
||||
|
||||
# inode -> (parent_inode, name)
|
||||
inode_path = {}
|
||||
# inode -> [(child_inode, name, type)]
|
||||
inode_children = collections.defaultdict(list)
|
||||
|
||||
processed = 0
|
||||
for dir_ino in dir_inodes:
|
||||
stdout, _ = run(['fls', DEVICE, str(dir_ino)], timeout=30)
|
||||
parent_ino = None
|
||||
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) < 3: continue
|
||||
type_str = parts[0]
|
||||
ino_str = parts[1].rstrip(':').lstrip('*')
|
||||
name = parts[2].strip()
|
||||
ino = int(ino_str)
|
||||
etype = type_str[0]
|
||||
|
||||
if name == '..':
|
||||
parent_ino = ino
|
||||
continue
|
||||
if name == '.':
|
||||
continue
|
||||
|
||||
inode_children[dir_ino].append((ino, name, etype))
|
||||
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if parent_ino is not None:
|
||||
inode_path[dir_ino] = parent_ino
|
||||
|
||||
processed += 1
|
||||
if processed % 1000 == 0:
|
||||
print(f' Processed {processed}/{len(dir_inodes)} directories...',
|
||||
flush=True)
|
||||
|
||||
return inode_path, inode_children
|
||||
|
||||
# ── Phase 3: resolve paths ────────────────────────────────────────────────────
|
||||
def resolve_paths(inode_path, inode_children, all_inodes):
|
||||
"""
|
||||
Walk parent pointers to build full paths for each directory.
|
||||
Directories whose parent chain leads to a lost inode go to /orphans/.
|
||||
"""
|
||||
print('\nResolving full paths...')
|
||||
|
||||
# resolved_dirs: inode -> full path string
|
||||
resolved = {}
|
||||
|
||||
def get_path(ino, depth=0):
|
||||
if depth > 50: # cycle protection
|
||||
return None
|
||||
if ino in resolved:
|
||||
return resolved[ino]
|
||||
|
||||
parent = inode_path.get(ino)
|
||||
if parent is None or parent == ino:
|
||||
# Root or unknown parent
|
||||
path = f'orphans/dir_{ino}'
|
||||
resolved[ino] = path
|
||||
return path
|
||||
|
||||
grp = (parent - 1) // IPG
|
||||
if grp < MIN_GOOD_GROUP:
|
||||
# Parent is in lost group — this is an orphan root
|
||||
# Try to find the directory name from the parent's children
|
||||
# We can't — parent inode is gone
|
||||
path = f'orphans/dir_{ino}'
|
||||
resolved[ino] = path
|
||||
return path
|
||||
|
||||
parent_path = get_path(parent, depth + 1)
|
||||
if parent_path is None:
|
||||
path = f'orphans/dir_{ino}'
|
||||
else:
|
||||
# Find our name in parent's children
|
||||
name = f'inode_{ino}'
|
||||
for child_ino, child_name, _ in inode_children.get(parent, []):
|
||||
if child_ino == ino:
|
||||
name = child_name
|
||||
break
|
||||
path = os.path.join(parent_path, name)
|
||||
|
||||
resolved[ino] = path
|
||||
return path
|
||||
|
||||
for ino in inode_path:
|
||||
get_path(ino)
|
||||
|
||||
return resolved
|
||||
|
||||
# ── Phase 4: extract ──────────────────────────────────────────────────────────
|
||||
def extract_all(resolved_dirs, inode_children, all_inodes):
|
||||
print(f'\nExtracting files...')
|
||||
stats = {'ok': 0, 'err': 0, 'bytes': 0}
|
||||
extracted = set()
|
||||
|
||||
# Extract files reachable from directory tree
|
||||
for dir_ino, dir_path in resolved_dirs.items():
|
||||
abs_dir = os.path.join(OUTDIR, dir_path)
|
||||
os.makedirs(abs_dir, exist_ok=True)
|
||||
|
||||
for child_ino, name, etype in inode_children.get(dir_ino, []):
|
||||
if child_ino in extracted:
|
||||
continue
|
||||
outpath = os.path.join(abs_dir, name)
|
||||
|
||||
if etype == 'r':
|
||||
try:
|
||||
os.makedirs(abs_dir, exist_ok=True)
|
||||
with open(outpath, 'wb') as f:
|
||||
subprocess.run(
|
||||
['icat', DEVICE, str(child_ino)],
|
||||
stdout=f, stderr=subprocess.DEVNULL,
|
||||
timeout=600
|
||||
)
|
||||
size = os.path.getsize(outpath)
|
||||
stats['ok'] += 1
|
||||
stats['bytes'] += size
|
||||
extracted.add(child_ino)
|
||||
if stats['ok'] % 100 == 0:
|
||||
print(f' {stats["ok"]} files extracted, '
|
||||
f'{stats["bytes"]/1024**3:.2f}GB...', flush=True)
|
||||
except Exception as e:
|
||||
stats['err'] += 1
|
||||
|
||||
elif etype == 'l':
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['icat', DEVICE, str(child_ino)],
|
||||
capture_output=True, timeout=10
|
||||
)
|
||||
target = r.stdout.decode('utf-8', errors='replace').strip()
|
||||
if target:
|
||||
if os.path.lexists(outpath): os.remove(outpath)
|
||||
os.symlink(target, outpath)
|
||||
extracted.add(child_ino)
|
||||
stats['ok'] += 1
|
||||
except:
|
||||
stats['err'] += 1
|
||||
|
||||
# Extract orphaned files (allocated but not in any directory)
|
||||
print(f'\nExtracting orphaned files...')
|
||||
orphan_dir = os.path.join(OUTDIR, 'orphans', 'files')
|
||||
os.makedirs(orphan_dir, exist_ok=True)
|
||||
|
||||
for ino, info in all_inodes.items():
|
||||
if ino in extracted: continue
|
||||
if info['type'] != 'f': continue
|
||||
if info['size'] == 0: continue
|
||||
|
||||
outpath = os.path.join(orphan_dir, str(ino))
|
||||
try:
|
||||
with open(outpath, 'wb') as f:
|
||||
subprocess.run(
|
||||
['icat', DEVICE, str(ino)],
|
||||
stdout=f, stderr=subprocess.DEVNULL,
|
||||
timeout=600
|
||||
)
|
||||
size = os.path.getsize(outpath)
|
||||
if size > 0:
|
||||
stats['ok'] += 1
|
||||
stats['bytes'] += size
|
||||
extracted.add(ino)
|
||||
else:
|
||||
os.remove(outpath)
|
||||
except:
|
||||
stats['err'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def main():
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
print(f'Device : {DEVICE}')
|
||||
print(f'Output : {OUTDIR}')
|
||||
print()
|
||||
|
||||
# Phase 1: enumerate all inodes
|
||||
all_inodes = get_all_inodes()
|
||||
if not all_inodes:
|
||||
print('ERROR: ils returned no inodes - is NBD server running?')
|
||||
sys.exit(1)
|
||||
|
||||
dir_inodes = [ino for ino, info in all_inodes.items()
|
||||
if info['type'] == 'd']
|
||||
|
||||
# Phase 2: build tree
|
||||
inode_path, inode_children = build_tree(dir_inodes)
|
||||
|
||||
# Phase 3: resolve paths
|
||||
resolved_dirs = resolve_paths(inode_path, inode_children, all_inodes)
|
||||
|
||||
intact = sum(1 for p in resolved_dirs.values()
|
||||
if not p.startswith('orphans'))
|
||||
orphaned = sum(1 for p in resolved_dirs.values()
|
||||
if p.startswith('orphans'))
|
||||
print(f'Directories with resolved paths: {intact}')
|
||||
print(f'Orphaned directories: {orphaned}')
|
||||
|
||||
# Phase 4: extract
|
||||
stats = extract_all(resolved_dirs, inode_children, all_inodes)
|
||||
|
||||
print()
|
||||
print('=== COMPLETE ===')
|
||||
print(f'Files OK: {stats["ok"]}')
|
||||
print(f'Files ERR: {stats["err"]}')
|
||||
print(f'Total data: {stats["bytes"]/1024**3:.2f} GB')
|
||||
print(f'Output: {OUTDIR}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
207
test/recursivedump.py
Normal file
207
test/recursivedump.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recursive ext4 directory dumper by inode number.
|
||||
Bypasses all metadata validation - uses extent trees directly.
|
||||
"""
|
||||
import struct, os, sys, stat
|
||||
from pathlib import Path
|
||||
|
||||
DEV = '/dev/dm-0'
|
||||
BLOCK = 4096
|
||||
BACKUP_SB_BLOCK = 32768
|
||||
|
||||
# ── low-level helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def read_at(f, offset, size):
|
||||
f.seek(offset)
|
||||
return f.read(size)
|
||||
|
||||
def parse_superblock(data):
|
||||
sb = {}
|
||||
sb['inodes_count'] = struct.unpack_from('<I', data, 0)[0]
|
||||
sb['blocks_count'] = struct.unpack_from('<I', data, 4)[0]
|
||||
sb['blocks_per_group'] = struct.unpack_from('<I', data, 32)[0]
|
||||
sb['inodes_per_group'] = struct.unpack_from('<I', data, 40)[0]
|
||||
sb['inode_size'] = struct.unpack_from('<H', data, 88)[0]
|
||||
sb['magic'] = struct.unpack_from('<H', data, 56)[0]
|
||||
sb['desc_size'] = struct.unpack_from('<H', data, 254)[0] or 32
|
||||
return sb
|
||||
|
||||
def parse_gdt_entry(gdt_data, offset, desc_size):
|
||||
lo = struct.unpack_from('<I', gdt_data, offset + 8)[0]
|
||||
if desc_size >= 64:
|
||||
hi = struct.unpack_from('<I', gdt_data, offset + 40)[0]
|
||||
return lo | (hi << 32)
|
||||
return lo
|
||||
|
||||
def parse_extent_tree(data, inode_offset):
|
||||
base = inode_offset + 40
|
||||
magic, entries, _, depth = struct.unpack_from('<HHHH', data, base)
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
extents = []
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
o = base + 12 + i * 12
|
||||
if o + 12 > len(data): break
|
||||
l_block = struct.unpack_from('<I', data, o )[0]
|
||||
ee_len = struct.unpack_from('<H', data, o + 4)[0]
|
||||
start_hi = struct.unpack_from('<H', data, o + 6)[0]
|
||||
start_lo = struct.unpack_from('<I', data, o + 8)[0]
|
||||
phys = (start_hi << 32) | start_lo
|
||||
if phys > 0:
|
||||
extents.append((l_block, phys, ee_len & 0x7FFF))
|
||||
else:
|
||||
# Depth > 0: extent index node - follow first child
|
||||
# (handles large dirs gracefully)
|
||||
o = base + 12
|
||||
ei_leaf_lo = struct.unpack_from('<I', data, o + 4)[0]
|
||||
ei_leaf_hi = struct.unpack_from('<H', data, o + 8)[0]
|
||||
extents.append((0, (ei_leaf_hi << 32) | ei_leaf_lo, 1))
|
||||
return extents
|
||||
|
||||
def read_inode(f, sb, gdt_data, inum):
|
||||
"""Return raw inode block data and offset within it."""
|
||||
grp = (inum - 1) // sb['inodes_per_group']
|
||||
local_idx = (inum - 1) % sb['inodes_per_group']
|
||||
tbl_block = parse_gdt_entry(gdt_data, grp * sb['desc_size'], sb['desc_size'])
|
||||
byte_off = local_idx * sb['inode_size']
|
||||
blk_off = byte_off // BLOCK
|
||||
slot = byte_off % BLOCK
|
||||
data = read_at(f, (tbl_block + blk_off) * BLOCK, BLOCK)
|
||||
return data, slot
|
||||
|
||||
def read_dir_entries(f, sb, gdt_data, inum):
|
||||
"""Return dict of name -> (child_inum, ftype)."""
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
entries = {}
|
||||
for _, phys, length in parse_extent_tree(idata, slot):
|
||||
for blk in range(length):
|
||||
try:
|
||||
bdata = read_at(f, (phys + blk) * BLOCK, BLOCK)
|
||||
offset = 0
|
||||
while offset < BLOCK - 8:
|
||||
e_ino, rec_len, name_len, ftype = \
|
||||
struct.unpack_from('<IHBB', bdata, offset)
|
||||
if rec_len < 8 or offset + rec_len > BLOCK:
|
||||
break
|
||||
if e_ino != 0 and name_len > 0:
|
||||
name = bdata[offset+8:offset+8+name_len]\
|
||||
.decode('utf-8', errors='replace')
|
||||
entries[name] = (e_ino, ftype)
|
||||
offset += rec_len
|
||||
except OSError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def dump_file(f, sb, gdt_data, inum, dest_path):
|
||||
"""Extract a regular file by inode to dest_path."""
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
size_lo = struct.unpack_from('<I', idata, slot + 4)[0]
|
||||
size_hi = struct.unpack_from('<I', idata, slot + 108)[0]
|
||||
size = size_lo | (size_hi << 32)
|
||||
extents = parse_extent_tree(idata, slot)
|
||||
|
||||
# Check for inline data (EXT4_INLINE_DATA_FL = 0x10000000)
|
||||
flags = struct.unpack_from('<I', idata, slot + 32)[0]
|
||||
if flags & 0x10000000:
|
||||
# Data stored in inode body - skip for now
|
||||
return False
|
||||
|
||||
written = 0
|
||||
with open(dest_path, 'wb') as out:
|
||||
for _, phys, length in sorted(extents):
|
||||
for blk in range(length):
|
||||
if written >= size:
|
||||
break
|
||||
chunk = read_at(f, (phys + blk) * BLOCK, BLOCK)
|
||||
remaining = size - written
|
||||
out.write(chunk[:remaining] if remaining < BLOCK else chunk)
|
||||
written += min(BLOCK, remaining)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def dump_symlink(f, sb, gdt_data, inum, dest_path):
|
||||
"""Extract symlink target."""
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
size = struct.unpack_from('<I', idata, slot + 4)[0]
|
||||
if size <= 60:
|
||||
# Fast symlink - target in inode block area
|
||||
target = idata[slot+40:slot+40+size].decode('utf-8', errors='replace')
|
||||
else:
|
||||
extents = parse_extent_tree(idata, slot)
|
||||
if not extents:
|
||||
return False
|
||||
bdata = read_at(f, extents[0][1] * BLOCK, BLOCK)
|
||||
target = bdata[:size].decode('utf-8', errors='replace')
|
||||
os.symlink(target, dest_path)
|
||||
return True
|
||||
except (OSError, IndexError):
|
||||
return False
|
||||
|
||||
# ── recursive dumper ─────────────────────────────────────────────────────────
|
||||
|
||||
FTYPE_REG = 1
|
||||
FTYPE_DIR = 2
|
||||
FTYPE_SYM = 7
|
||||
|
||||
def dump_tree(f, sb, gdt_data, inum, dest_dir, depth=0, visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if inum in visited:
|
||||
return
|
||||
visited.add(inum)
|
||||
|
||||
try:
|
||||
entries = read_dir_entries(f, sb, gdt_data, inum)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
for name, (child_inum, ftype) in entries.items():
|
||||
if name in ('.', '..'):
|
||||
continue
|
||||
# Sanitise name
|
||||
safe_name = name.replace('/', '_').replace('\x00', '')
|
||||
dest = os.path.join(dest_dir, safe_name)
|
||||
|
||||
try:
|
||||
if ftype == FTYPE_DIR:
|
||||
dump_tree(f, sb, gdt_data, child_inum, dest, depth+1, visited)
|
||||
elif ftype == FTYPE_REG:
|
||||
dump_file(f, sb, gdt_data, child_inum, dest)
|
||||
elif ftype == FTYPE_SYM:
|
||||
dump_symlink(f, sb, gdt_data, child_inum, dest)
|
||||
except Exception as e:
|
||||
print(f" WARN: {dest}: {e}", file=sys.stderr)
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(f"Usage: {sys.argv[0]} <inode> <dest_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
root_inum = int(sys.argv[1])
|
||||
dest_dir = sys.argv[2]
|
||||
|
||||
with open(DEV, 'rb') as f:
|
||||
sb_data = read_at(f, BACKUP_SB_BLOCK * BLOCK, 1024)
|
||||
sb = parse_superblock(sb_data)
|
||||
assert sb['magic'] == 0xef53
|
||||
|
||||
num_groups = (sb['blocks_count'] + sb['blocks_per_group'] - 1) \
|
||||
// sb['blocks_per_group']
|
||||
gdt_data = read_at(f, (BACKUP_SB_BLOCK + 1) * BLOCK,
|
||||
num_groups * sb['desc_size'])
|
||||
|
||||
print(f"Dumping inode {root_inum} -> {dest_dir}")
|
||||
dump_tree(f, sb, gdt_data, root_inum, dest_dir)
|
||||
print("Done")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
227
test/rescue.sh
Normal file
227
test/rescue.sh
Normal file
@@ -0,0 +1,227 @@
|
||||
cat > /tmp/reconstruct_tree.py << 'EOF'
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reconstruct full directory tree from inode tables.
|
||||
Attaches orphaned subtrees to lost+found.
|
||||
Extracts everything using icat/debugfs.
|
||||
"""
|
||||
import struct, os, subprocess, collections
|
||||
|
||||
DEVICE = '/dev/nbd0'
|
||||
OUTDIR = '/mnt/recovered/reconstructed'
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
INODE_SZ = 256
|
||||
NUM_GROUPS = 35728
|
||||
MIN_GROUP = 13
|
||||
|
||||
# inode -> parent_inode (from .. entry)
|
||||
parent_of = {}
|
||||
# inode -> [(child_inode, name, ftype)]
|
||||
children = collections.defaultdict(list)
|
||||
# inode -> name (as seen from parent's directory block)
|
||||
inode_name = {}
|
||||
# all directory inodes found
|
||||
dir_inodes = set()
|
||||
|
||||
def parse_extents(inode_data):
|
||||
blocks = []
|
||||
magic = struct.unpack_from('<H', inode_data, 40)[0]
|
||||
if magic != 0xf30a: return blocks
|
||||
entries = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
off = 52 + i*12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
ee_start = (ee_hi<<32)|ee_lo
|
||||
if ee_len > 1024: continue
|
||||
for b in range(min(ee_len, 8)):
|
||||
blocks.append(ee_start + b)
|
||||
return blocks
|
||||
|
||||
def read_dirents(f, inode_data):
|
||||
entries = []
|
||||
for blk in parse_extents(inode_data):
|
||||
try:
|
||||
f.seek(blk * BSIZE)
|
||||
data = f.read(BSIZE)
|
||||
except OSError:
|
||||
continue
|
||||
off = 0
|
||||
while off < BSIZE - 8:
|
||||
ino = struct.unpack_from('<I', data, off)[0]
|
||||
rec_len = struct.unpack_from('<H', data, off+4)[0]
|
||||
name_len = data[off+6]
|
||||
ftype = data[off+7]
|
||||
if rec_len < 8: break
|
||||
if ino > 0 and name_len > 0:
|
||||
name = data[off+8:off+8+name_len].decode('utf-8',errors='replace')
|
||||
entries.append((ino, name, ftype))
|
||||
off += rec_len
|
||||
return entries
|
||||
|
||||
# ── Phase 1: scan all inode tables ───────────────────────────────────────────
|
||||
print('Phase 1: Scanning inode tables...')
|
||||
with open(DEVICE, 'rb', buffering=0) as f:
|
||||
for group in range(MIN_GROUP, NUM_GROUPS):
|
||||
it_block = 1070 + group * 512
|
||||
try:
|
||||
f.seek(it_block * BSIZE)
|
||||
inode_table = f.read(IPG * INODE_SZ)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for idx in range(IPG):
|
||||
inode_data = inode_table[idx*INODE_SZ:(idx+1)*INODE_SZ]
|
||||
if not any(inode_data): continue
|
||||
|
||||
mode = struct.unpack_from('<H', inode_data, 0)[0]
|
||||
links = struct.unpack_from('<H', inode_data, 26)[0]
|
||||
|
||||
if (mode & 0xf000) != 0x4000: continue
|
||||
if links < 2: continue
|
||||
|
||||
inode_num = group * IPG + idx + 1
|
||||
dir_inodes.add(inode_num)
|
||||
|
||||
# Read directory entries to find parent and children
|
||||
entries = read_dirents(f, inode_data)
|
||||
for ino, name, ftype in entries:
|
||||
if name == '..':
|
||||
parent_of[inode_num] = ino
|
||||
elif name != '.':
|
||||
children[inode_num].append((ino, name, ftype))
|
||||
inode_name[ino] = name
|
||||
|
||||
if group % 2000 == 0:
|
||||
print(f' Group {group}/{NUM_GROUPS}: '
|
||||
f'{len(dir_inodes)} dirs found...', flush=True)
|
||||
|
||||
print(f'Found {len(dir_inodes)} directory inodes')
|
||||
print(f'Found {len(parent_of)} directories with known parents')
|
||||
|
||||
# ── Phase 2: find orphan roots ────────────────────────────────────────────────
|
||||
print('\nPhase 2: Finding orphan roots...')
|
||||
|
||||
def resolve_path(inode, depth=0, visited=None):
|
||||
if visited is None: visited = set()
|
||||
if inode in visited: return None
|
||||
visited.add(inode)
|
||||
if depth > 50: return None
|
||||
|
||||
parent = parent_of.get(inode)
|
||||
if parent is None:
|
||||
return f'lost+found/unknown_{inode}'
|
||||
|
||||
grp = (parent-1) // IPG
|
||||
if grp < MIN_GROUP:
|
||||
# Parent is in zeroed region - this is an orphan root
|
||||
name = inode_name.get(inode, f'inode_{inode}')
|
||||
return f'lost+found/{name}_{inode}'
|
||||
|
||||
if parent not in dir_inodes:
|
||||
# Parent not found in our scan
|
||||
name = inode_name.get(inode, f'inode_{inode}')
|
||||
return f'lost+found/{name}_{inode}'
|
||||
|
||||
parent_path = resolve_path(parent, depth+1, visited)
|
||||
if parent_path is None:
|
||||
return f'lost+found/inode_{inode}'
|
||||
|
||||
name = inode_name.get(inode, f'inode_{inode}')
|
||||
return os.path.join(parent_path, name)
|
||||
|
||||
# Resolve paths for all directories
|
||||
print('Resolving paths...')
|
||||
resolved = {}
|
||||
for ino in dir_inodes:
|
||||
resolved[ino] = resolve_path(ino)
|
||||
|
||||
# Summary
|
||||
in_lf = sum(1 for p in resolved.values() if p and p.startswith('lost+found/')
|
||||
and p.count('/') == 1)
|
||||
deep = sum(1 for p in resolved.values() if p and not p.startswith('lost+found'))
|
||||
print(f'Orphan roots in lost+found: {in_lf}')
|
||||
print(f'Dirs with resolved paths: {deep}')
|
||||
|
||||
# Show interesting paths
|
||||
print('\nInteresting resolved paths:')
|
||||
for ino, path in sorted(resolved.items(), key=lambda x: x[1] or ''):
|
||||
if path and any(x in path for x in ['pterodactyl','docker','mysql',
|
||||
'www','nginx','var','log']):
|
||||
print(f' inode {ino:10d}: {path}')
|
||||
|
||||
# Save tree
|
||||
with open('/tmp/resolved_tree.txt','w') as f:
|
||||
for ino, path in sorted(resolved.items(), key=lambda x: x[1] or ''):
|
||||
f.write(f'{ino}\t{path or "unknown"}\n')
|
||||
print(f'\nSaved {len(resolved)} paths to /tmp/resolved_tree.txt')
|
||||
|
||||
# ── Phase 3: extract ──────────────────────────────────────────────────────────
|
||||
print('\nPhase 3: Extracting...')
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
|
||||
stats = {'dirs':0, 'files_ok':0, 'files_err':0, 'bytes':0}
|
||||
|
||||
# Create all directories first
|
||||
for ino, path in sorted(resolved.items(), key=lambda x: len(x[1] or '')):
|
||||
if not path: continue
|
||||
abs_path = os.path.join(OUTDIR, path)
|
||||
os.makedirs(abs_path, exist_ok=True)
|
||||
stats['dirs'] += 1
|
||||
|
||||
# Extract files in each directory
|
||||
for dir_ino, path in resolved.items():
|
||||
if not path: continue
|
||||
abs_dir = os.path.join(OUTDIR, path)
|
||||
|
||||
for child_ino, name, ftype in children.get(dir_ino, []):
|
||||
# Skip if it's a directory (already created)
|
||||
if child_ino in dir_inodes: continue
|
||||
|
||||
outpath = os.path.join(abs_dir, name)
|
||||
if ftype == 1: # regular file
|
||||
try:
|
||||
with open(outpath, 'wb') as out:
|
||||
subprocess.run(
|
||||
['icat', DEVICE, str(child_ino)],
|
||||
stdout=out, stderr=subprocess.DEVNULL,
|
||||
timeout=300
|
||||
)
|
||||
size = os.path.getsize(outpath)
|
||||
stats['files_ok'] += 1
|
||||
stats['bytes'] += size
|
||||
if stats['files_ok'] % 500 == 0:
|
||||
print(f' {stats["files_ok"]} files, '
|
||||
f'{stats["bytes"]/1024**3:.2f}GB...', flush=True)
|
||||
except Exception as e:
|
||||
stats['files_err'] += 1
|
||||
|
||||
elif ftype == 7: # symlink
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['icat', DEVICE, str(child_ino)],
|
||||
capture_output=True, timeout=10
|
||||
)
|
||||
target = r.stdout.decode('utf-8',errors='replace').strip()
|
||||
if target:
|
||||
if os.path.lexists(outpath): os.remove(outpath)
|
||||
os.symlink(target, outpath)
|
||||
stats['files_ok'] += 1
|
||||
except:
|
||||
stats['files_err'] += 1
|
||||
|
||||
print()
|
||||
print('=== COMPLETE ===')
|
||||
print(f'Directories: {stats["dirs"]}')
|
||||
print(f'Files OK: {stats["files_ok"]}')
|
||||
print(f'Files ERR: {stats["files_err"]}')
|
||||
print(f'Total data: {stats["bytes"]/1024**3:.2f}GB')
|
||||
print(f'Output: {OUTDIR}')
|
||||
|
||||
EOF
|
||||
|
||||
python3 /tmp/reconstruct_tree.py 2>&1 | tee /tmp/reconstruct.log
|
||||
131
test/restore_meta.py
Normal file
131
test/restore_meta.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Restore ext4 metadata (permissions, ownership, timestamps) to a recovered tree.
|
||||
Run after dump_tree.py has extracted files.
|
||||
|
||||
Usage: python3 restore_meta.py <inode> <dest_dir>
|
||||
"""
|
||||
import struct, os, sys, stat, ctypes, ctypes.util
|
||||
|
||||
DEV = '/dev/dm-0'
|
||||
BLOCK = 4096
|
||||
BACKUP_SB_BLOCK = 32768
|
||||
|
||||
# ── reuse same low-level helpers from dump_tree.py ───────────────────────────
|
||||
# (paste parse_superblock, parse_gdt_entry, read_at, read_inode,
|
||||
# read_extent_tree_blocks, read_dir_entries here)
|
||||
# or factor them into a shared ext4lib.py and import from both scripts
|
||||
|
||||
import ext4lib
|
||||
|
||||
libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True)
|
||||
|
||||
class Timeval(ctypes.Structure):
|
||||
_fields_ = [('tv_sec', ctypes.c_long), ('tv_usec', ctypes.c_long)]
|
||||
|
||||
def lutimes(path, atime, mtime):
|
||||
times = (Timeval * 2)((atime, 0), (mtime, 0))
|
||||
libc.lutimes(path.encode(), ctypes.byref(times))
|
||||
|
||||
def get_inode_meta(idata, slot, sb):
|
||||
mode = struct.unpack_from('<H', idata, slot + 0)[0]
|
||||
uid = struct.unpack_from('<H', idata, slot + 2)[0]
|
||||
gid = struct.unpack_from('<H', idata, slot + 24)[0]
|
||||
atime = struct.unpack_from('<I', idata, slot + 8)[0]
|
||||
mtime = struct.unpack_from('<I', idata, slot + 16)[0]
|
||||
|
||||
if sb['inode_size'] >= 256:
|
||||
atime_extra = struct.unpack_from('<I', idata, slot + 132)[0]
|
||||
mtime_extra = struct.unpack_from('<I', idata, slot + 140)[0]
|
||||
atime |= (atime_extra & 0x3) << 32
|
||||
mtime |= (mtime_extra & 0x3) << 32
|
||||
|
||||
uid_hi, gid_hi = struct.unpack_from('<HH', idata, slot + 120)
|
||||
uid |= uid_hi << 16
|
||||
gid |= gid_hi << 16
|
||||
|
||||
return stat.S_IMODE(mode), uid, gid, atime, mtime
|
||||
|
||||
def restore_meta(f, sb, gdt_data, inum, dest_path):
|
||||
try:
|
||||
idata, slot = ext4lib.read_inode(f, sb, gdt_data, inum)
|
||||
mode, uid, gid, atime, mtime = get_inode_meta(idata, slot, sb)
|
||||
is_symlink = os.path.islink(dest_path)
|
||||
|
||||
try:
|
||||
os.lchown(dest_path, uid, gid)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not is_symlink:
|
||||
try:
|
||||
os.chmod(dest_path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
lutimes(dest_path, atime, mtime)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f" WARN {dest_path}: {e}", file=sys.stderr)
|
||||
|
||||
def walk_and_restore(f, sb, gdt_data, inum, dest_dir, visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if inum in visited:
|
||||
return
|
||||
visited.add(inum)
|
||||
|
||||
# Restore the directory itself
|
||||
restore_meta(f, sb, gdt_data, inum, dest_dir)
|
||||
|
||||
try:
|
||||
entries = ext4lib.read_dir_entries(f, sb, gdt_data, inum)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for name, (child_inum, ftype) in entries.items():
|
||||
if name in ('.', '..'):
|
||||
continue
|
||||
safe_name = name.replace('/', '_').replace('\x00', '')
|
||||
dest = os.path.join(dest_dir, safe_name)
|
||||
|
||||
if not os.path.lexists(dest):
|
||||
# File wasn't recovered - skip
|
||||
continue
|
||||
|
||||
if os.path.isdir(dest) and not os.path.islink(dest):
|
||||
walk_and_restore(f, sb, gdt_data, child_inum, dest, visited)
|
||||
else:
|
||||
restore_meta(f, sb, gdt_data, child_inum, dest)
|
||||
|
||||
# Restore directory timestamps AFTER processing children
|
||||
# (writing children updates parent dir mtime/atime)
|
||||
restore_meta(f, sb, gdt_data, inum, dest_dir)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(f"Usage: {sys.argv[0]} <inode> <dest_dir>")
|
||||
sys.exit(1)
|
||||
|
||||
root_inum = int(sys.argv[1])
|
||||
dest_dir = sys.argv[2]
|
||||
|
||||
with open(DEV, 'rb') as f:
|
||||
sb_data = ext4lib.read_at(f, BACKUP_SB_BLOCK * BLOCK, 1024)
|
||||
sb = ext4lib.parse_superblock(sb_data)
|
||||
assert sb['magic'] == 0xef53
|
||||
|
||||
num_groups = (sb['blocks_count'] + sb['blocks_per_group'] - 1) \
|
||||
// sb['blocks_per_group']
|
||||
gdt_data = ext4lib.read_at(f, (BACKUP_SB_BLOCK + 1) * BLOCK,
|
||||
num_groups * sb['desc_size'])
|
||||
|
||||
print(f"Restoring metadata: inode {root_inum} -> {dest_dir}")
|
||||
walk_and_restore(f, sb, gdt_data, root_inum, dest_dir)
|
||||
print("Done")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
154
test/scan.py
Normal file
154
test/scan.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Strict ext4 directory entry scanner for pterodactyl paths.
|
||||
"""
|
||||
import struct
|
||||
|
||||
CHUNK = 128 * 512
|
||||
LV_START = 5120000 * 512
|
||||
BSIZE = 4096
|
||||
DISKS = ['/dev/sda', '/dev/sdd', '/dev/sdc', '/dev/sdb']
|
||||
|
||||
# Only exact target names we expect as directory entries
|
||||
EXACT_TARGETS = [
|
||||
b'pterodactyl',
|
||||
b'volumes',
|
||||
b'wings',
|
||||
]
|
||||
|
||||
def is_valid_dirent(block, off, name):
|
||||
"""Strict validation of an ext4 directory entry."""
|
||||
if off + 8 + len(name) > BSIZE:
|
||||
return False
|
||||
|
||||
inode = struct.unpack_from('<I', block, off)[0]
|
||||
rec_len = struct.unpack_from('<H', block, off+4)[0]
|
||||
name_len = block[off+6]
|
||||
ftype = block[off+7]
|
||||
|
||||
# inode must be plausible (> 10, not absurdly large)
|
||||
if not (10 < inode < 500_000_000):
|
||||
return False
|
||||
|
||||
# name_len must exactly match our target
|
||||
if name_len != len(name):
|
||||
return False
|
||||
|
||||
# rec_len must be >= 8 + name_len and <= 4096
|
||||
# and aligned to 4 bytes
|
||||
min_rec = 8 + name_len
|
||||
if rec_len < min_rec or rec_len > BSIZE or rec_len % 4 != 0:
|
||||
return False
|
||||
|
||||
# file type must be a known ext4 type
|
||||
if ftype not in (1, 2, 7): # file, dir, symlink only
|
||||
return False
|
||||
|
||||
# the name bytes must match exactly and be clean ASCII
|
||||
actual_name = block[off+8:off+8+name_len]
|
||||
if actual_name != name:
|
||||
return False
|
||||
|
||||
# byte immediately after name (padding) should be 0
|
||||
pad_off = off + 8 + name_len
|
||||
if pad_off < BSIZE and block[pad_off] != 0:
|
||||
return False
|
||||
|
||||
# Previous entry should also look valid if we're not at start of block
|
||||
# (skip this check for now - too complex)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def scan_block(block, phys_base):
|
||||
hits = []
|
||||
for off in range(0, BSIZE - 8):
|
||||
for target in EXACT_TARGETS:
|
||||
# Quick check: does target appear at this offset+8?
|
||||
if block[off+8:off+8+len(target)] != target:
|
||||
continue
|
||||
if is_valid_dirent(block, off, target):
|
||||
inode = struct.unpack_from('<I', block, off)[0]
|
||||
rec_len = struct.unpack_from('<H', block, off+4)[0]
|
||||
ftype = block[off+7]
|
||||
grp = (inode - 1) // 8192
|
||||
hits.append({
|
||||
'phys': phys_base + off,
|
||||
'inode': inode,
|
||||
'name': target.decode(),
|
||||
'ftype': {1:'file',2:'dir',7:'symlink'}.get(ftype,'?'),
|
||||
'group': grp,
|
||||
'intact': grp >= 13,
|
||||
'rec_len': rec_len,
|
||||
})
|
||||
return hits
|
||||
|
||||
|
||||
def iter_data_chunks(disk_path):
|
||||
with open(disk_path, 'rb') as f:
|
||||
f.seek(0, 2)
|
||||
disk_size = f.tell()
|
||||
|
||||
chunk_num = 0
|
||||
with open(disk_path, 'rb') as f:
|
||||
phys = LV_START
|
||||
while phys + CHUNK <= disk_size:
|
||||
if chunk_num % 5 != 4:
|
||||
f.seek(phys)
|
||||
yield phys, f.read(CHUNK)
|
||||
phys += CHUNK
|
||||
chunk_num += 1
|
||||
|
||||
|
||||
def main():
|
||||
all_hits = []
|
||||
|
||||
for disk_idx, disk in enumerate(DISKS):
|
||||
print(f'\nScanning {disk}...', flush=True)
|
||||
chunks = 0
|
||||
hits = 0
|
||||
|
||||
for phys, chunk_data in iter_data_chunks(disk):
|
||||
# Pre-filter: any target in chunk?
|
||||
if not any(t in chunk_data for t in EXACT_TARGETS):
|
||||
chunks += 1
|
||||
continue
|
||||
|
||||
# Scan each 4KB block in chunk
|
||||
for blk in range(0, len(chunk_data), BSIZE):
|
||||
block = chunk_data[blk:blk+BSIZE]
|
||||
for hit in scan_block(block, phys + blk):
|
||||
status = 'INTACT' if hit['intact'] else 'LOST'
|
||||
print(f" [{status}] '{hit['name']}' "
|
||||
f"inode={hit['inode']} "
|
||||
f"group={hit['group']} "
|
||||
f"type={hit['ftype']} "
|
||||
f"phys={hit['phys']}")
|
||||
all_hits.append((disk_idx, hit))
|
||||
hits += 1
|
||||
|
||||
chunks += 1
|
||||
if chunks % 5000 == 0:
|
||||
gb = (phys - LV_START) / 1024**3
|
||||
print(f' {disk}: {gb:.1f}GB, {hits} hits', flush=True)
|
||||
|
||||
print(f' Finished: {hits} hits')
|
||||
|
||||
print('\n=== RESULTS ===')
|
||||
# Group by name and inode
|
||||
from collections import defaultdict
|
||||
by_inode = defaultdict(list)
|
||||
for disk_idx, hit in all_hits:
|
||||
key = (hit['inode'], hit['name'])
|
||||
by_inode[key].append((DISKS[disk_idx], hit['phys']))
|
||||
|
||||
print(f'\nUnique (inode, name) pairs: {len(by_inode)}')
|
||||
for (inode, name), locations in sorted(by_inode.items()):
|
||||
grp = (inode-1)//8192
|
||||
status = 'INTACT' if grp >= 13 else 'LOST'
|
||||
print(f" '{name}' inode={inode} group={grp} [{status}]")
|
||||
for disk, phys in locations[:3]:
|
||||
print(f" {disk} phys={phys}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
63
test/scan1.sh
Normal file
63
test/scan1.sh
Normal file
@@ -0,0 +1,63 @@
|
||||
# debugfs can search for directory entries by name
|
||||
# even without a valid path
|
||||
debugfs -c /dev/nbd0 << 'EOF'
|
||||
stat <1585918>
|
||||
EOF
|
||||
|
||||
# Use the parent inode from stat to walk upward
|
||||
# inode 1585918 is 'volumes' - its .. entry points to pterodactyl dir
|
||||
# pterodactyl's .. points to lib
|
||||
# lib's .. points to var
|
||||
|
||||
# Find parent chain by reading .. entries
|
||||
python3 -c "
|
||||
import subprocess
|
||||
|
||||
def get_parent(inode):
|
||||
r = subprocess.run(
|
||||
['debugfs', '-c', '-R', f'ls -l <{inode}>', '/dev/nbd0'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for line in r.stdout.splitlines():
|
||||
if '..' in line:
|
||||
parts = line.split()
|
||||
for p in parts:
|
||||
try:
|
||||
n = int(p)
|
||||
if n > 0 and n != inode:
|
||||
return n
|
||||
except: pass
|
||||
return None
|
||||
|
||||
def get_name(inode):
|
||||
r = subprocess.run(
|
||||
['debugfs', '-c', '-R', f'stat <{inode}>', '/dev/nbd0'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return r.stdout[:200]
|
||||
|
||||
# Walk up from volumes inode
|
||||
inode = 1585918
|
||||
chain = [inode]
|
||||
print(f'Walking up from inode {inode} (volumes)...')
|
||||
for i in range(10):
|
||||
parent = get_parent(inode)
|
||||
if parent is None or parent == inode or parent in chain:
|
||||
break
|
||||
print(f' inode {inode} -> parent {parent}')
|
||||
chain.append(parent)
|
||||
inode = parent
|
||||
|
||||
print(f'Chain: {chain}')
|
||||
print()
|
||||
|
||||
# Now list each parent to find siblings of pterodactyl/volumes
|
||||
for ino in chain[1:]:
|
||||
print(f'Contents of inode {ino}:')
|
||||
r = subprocess.run(
|
||||
['debugfs', '-c', '-R', f'ls <{ino}>', '/dev/nbd0'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
print(r.stdout[:500])
|
||||
print()
|
||||
"
|
||||
242
test/scan_inodes.py
Normal file
242
test/scan_inodes.py
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Scan ext4 filesystem for orphaned directory roots.
|
||||
Reads inode table directly using geometry from backup superblock.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import defaultdict
|
||||
|
||||
DEV = '/dev/dm-0'
|
||||
BLOCK = 4096
|
||||
BACKUP_SB_BLOCK = 32768
|
||||
|
||||
def read_at(f, offset, size):
|
||||
f.seek(offset)
|
||||
return f.read(size)
|
||||
|
||||
def parse_superblock(data):
|
||||
sb = {}
|
||||
sb['inodes_count'] = struct.unpack_from('<I', data, 0)[0]
|
||||
sb['blocks_count'] = struct.unpack_from('<I', data, 4)[0]
|
||||
sb['blocks_per_group'] = struct.unpack_from('<I', data, 32)[0]
|
||||
sb['inodes_per_group'] = struct.unpack_from('<I', data, 40)[0]
|
||||
sb['inode_size'] = struct.unpack_from('<H', data, 88)[0]
|
||||
sb['magic'] = struct.unpack_from('<H', data, 56)[0]
|
||||
sb['feature_incompat'] = struct.unpack_from('<I', data, 96)[0]
|
||||
sb['desc_size'] = struct.unpack_from('<H', data, 254)[0] or 32
|
||||
return sb
|
||||
|
||||
def parse_gdt_entry(data, offset, desc_size):
|
||||
"""Parse group descriptor - handles both 32 and 64-bit descriptors"""
|
||||
inode_table_lo = struct.unpack_from('<I', data, offset + 8)[0]
|
||||
if desc_size >= 64:
|
||||
inode_table_hi = struct.unpack_from('<I', data, offset + 40)[0]
|
||||
return inode_table_lo | (inode_table_hi << 32)
|
||||
return inode_table_lo
|
||||
|
||||
def parse_extent_tree(data, inode_offset):
|
||||
base = inode_offset + 40
|
||||
magic, entries, max_entries, depth = struct.unpack_from('<HHHH', data, base)
|
||||
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
|
||||
extents = []
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
ext_off = base + 12 + i * 12
|
||||
if ext_off + 12 > len(data):
|
||||
break
|
||||
# Correct layout: l_block(4) + ee_len(2) + ee_start_hi(2) + ee_start_lo(4)
|
||||
l_block = struct.unpack_from('<I', data, ext_off)[0]
|
||||
ee_len = struct.unpack_from('<H', data, ext_off + 4)[0]
|
||||
start_hi = struct.unpack_from('<H', data, ext_off + 6)[0]
|
||||
start_lo = struct.unpack_from('<I', data, ext_off + 8)[0]
|
||||
phys = (start_hi << 32) | start_lo
|
||||
if phys > 0:
|
||||
extents.append((l_block, phys, ee_len & 0x7FFF))
|
||||
return extents
|
||||
|
||||
def read_dir_entries(f, inode_data, inode_offset):
|
||||
"""Read directory entries using extent tree from inode data"""
|
||||
extents = parse_extent_tree(inode_data, inode_offset)
|
||||
entries = {}
|
||||
for _, phys_block, length in extents[:1]: # first extent is enough for . and ..
|
||||
try:
|
||||
data = read_at(f, phys_block * BLOCK, BLOCK)
|
||||
offset = 0
|
||||
while offset < BLOCK - 8:
|
||||
ino, rec_len, name_len, ftype = struct.unpack_from(
|
||||
'<IHBB', data, offset)
|
||||
if rec_len < 8 or offset + rec_len > BLOCK:
|
||||
break
|
||||
if ino != 0 and name_len > 0:
|
||||
name = data[offset+8:offset+8+name_len].decode(
|
||||
'utf-8', errors='replace')
|
||||
entries[name] = (ino, ftype)
|
||||
offset += rec_len
|
||||
except OSError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def parse_inode(data, offset):
|
||||
if len(data) - offset < 128:
|
||||
return None
|
||||
mode, uid, size_lo = struct.unpack_from('<HHI', data, offset)
|
||||
atime, ctime, mtime, dtime = struct.unpack_from('<IIII', data, offset + 8)
|
||||
links_count = struct.unpack_from('<H', data, offset + 26)[0]
|
||||
# block pointers start at offset 40, 60 bytes (12 direct + ind + dind + tind)
|
||||
block0 = struct.unpack_from('<I', data, offset + 40)[0]
|
||||
return {
|
||||
'mode': mode,
|
||||
'type': mode & 0xF000,
|
||||
'links': links_count,
|
||||
'ctime': ctime,
|
||||
'mtime': mtime,
|
||||
'block0': block0, # first direct block pointer
|
||||
}
|
||||
|
||||
def main():
|
||||
with open(DEV, 'rb') as f:
|
||||
# Read backup superblock (no +1024 offset for backup blocks)
|
||||
sb_data = read_at(f, BACKUP_SB_BLOCK * BLOCK, 1024)
|
||||
sb = parse_superblock(sb_data)
|
||||
# After parsing superblock, check feature flags
|
||||
|
||||
INCOMPAT_EXTENTS = 0x40
|
||||
uses_extents = sb['feature_incompat'] & INCOMPAT_EXTENTS
|
||||
print(f"Extent trees: {'yes' if uses_extents else 'no'}")
|
||||
|
||||
assert sb['magic'] == 0xef53, f"Bad SB magic: {sb['magic']:#x}"
|
||||
print(f"Geometry: {sb['blocks_per_group']} blk/grp, "
|
||||
f"{sb['inodes_per_group']} ino/grp, "
|
||||
f"inode_size={sb['inode_size']}, "
|
||||
f"desc_size={sb['desc_size']}")
|
||||
|
||||
num_groups = (sb['blocks_count'] + sb['blocks_per_group'] - 1) \
|
||||
// sb['blocks_per_group']
|
||||
print(f"Total groups: {num_groups}, scanning from group 13+")
|
||||
|
||||
# Read GDT from backup location (block after backup SB)
|
||||
gdt_data = read_at(f, (BACKUP_SB_BLOCK + 1) * BLOCK,
|
||||
num_groups * sb['desc_size'])
|
||||
|
||||
# Map: inode_num -> (parent_inode, group, name)
|
||||
# We collect (dot_inode, dotdot_inode) for every dir we find
|
||||
dir_parents = {} # inode -> parent_inode
|
||||
all_dirs = set()
|
||||
|
||||
for grp in range(13, num_groups):
|
||||
inode_table_block = parse_gdt_entry(
|
||||
gdt_data, grp * sb['desc_size'], sb['desc_size'])
|
||||
if inode_table_block == 0:
|
||||
continue
|
||||
|
||||
inodes_per_block = BLOCK // sb['inode_size']
|
||||
num_inode_blocks = (sb['inodes_per_group'] * sb['inode_size']
|
||||
+ BLOCK - 1) // BLOCK
|
||||
|
||||
for blk_off in range(num_inode_blocks):
|
||||
try:
|
||||
idata = read_at(f,
|
||||
(inode_table_block + blk_off) * BLOCK, BLOCK)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for slot in range(inodes_per_block):
|
||||
ino_off = slot * sb['inode_size']
|
||||
ino = parse_inode(idata, ino_off)
|
||||
if ino is None:
|
||||
continue
|
||||
if ino['type'] != 0x4000: # S_IFDIR
|
||||
continue
|
||||
if ino['links'] == 0:
|
||||
continue
|
||||
|
||||
abs_inum = (grp * sb['inodes_per_group']
|
||||
+ blk_off * inodes_per_block
|
||||
+ slot + 1)
|
||||
all_dirs.add(abs_inum)
|
||||
# Add this debug block right after all_dirs.add(abs_inum)
|
||||
# Just for the first 5 dirs found, dump raw extent header
|
||||
if len(all_dirs) <= 5:
|
||||
base = ino_off + 40
|
||||
raw = idata[base:base+24]
|
||||
magic, entries_cnt, max_e, depth = struct.unpack_from('<HHHH', raw, 0)
|
||||
print(f"\nDEBUG inode {abs_inum} grp={grp}:")
|
||||
print(f" raw bytes: {raw.hex()}")
|
||||
print(f" extent header: magic={magic:#06x} entries={entries_cnt} depth={depth}")
|
||||
if len(raw) >= 24:
|
||||
l_block, len_blks, start_hi, start_lo = struct.unpack_from('<IIHH', raw, 12)
|
||||
phys = (start_hi << 32) | start_lo
|
||||
print(f" first extent: l_block={l_block} phys={phys} len={len_blks}")
|
||||
# Try reading what's at that block
|
||||
if phys > 0:
|
||||
try:
|
||||
ddata = read_at(f, phys * BLOCK, 32)
|
||||
print(f" block {phys} first 32 bytes: {ddata.hex()}")
|
||||
# Check if it looks like a dir entry
|
||||
ino2, rec2, nlen2, ft2 = struct.unpack_from('<IHBB', ddata, 0)
|
||||
print(f" as dir entry: inode={ino2} rec_len={rec2} name_len={nlen2}")
|
||||
except OSError as e:
|
||||
print(f" read error: {e}")
|
||||
|
||||
entries = read_dir_entries(f, idata, ino_off)
|
||||
|
||||
dot = entries.get('.', (None,))[0]
|
||||
dotdot = entries.get('..', (None,))[0]
|
||||
|
||||
if dot == abs_inum and dotdot is not None:
|
||||
dir_parents[abs_inum] = dotdot
|
||||
|
||||
if grp % 100 == 0:
|
||||
print(f" scanned group {grp}/{num_groups}, "
|
||||
f"dirs so far: {len(all_dirs)}",
|
||||
end='\r', flush=True)
|
||||
|
||||
print(f"\nTotal dirs found: {len(all_dirs)}")
|
||||
print(f"Dirs with readable . and ..: {len(dir_parents)}")
|
||||
|
||||
FIRST_GOOD_INODE = 13 * 8192 # first inode in group 13
|
||||
|
||||
orphan_roots = []
|
||||
for inum, parent in dir_parents.items():
|
||||
if parent == inum:
|
||||
orphan_roots.append((inum, parent, 'self-referential'))
|
||||
elif parent < FIRST_GOOD_INODE:
|
||||
# parent is in zeroed region - this is a detached root
|
||||
orphan_roots.append((inum, parent, 'parent-in-zeroed-region'))
|
||||
elif parent not in all_dirs:
|
||||
orphan_roots.append((inum, parent, 'parent-missing'))
|
||||
|
||||
# Build set of all orphaned inodes
|
||||
orphan_inums = {inum for inum, parent, reason in orphan_roots}
|
||||
|
||||
# True roots: orphans whose parent is not itself an orphan
|
||||
true_roots = [(inum, parent, reason)
|
||||
for inum, parent, reason in orphan_roots
|
||||
if parent not in orphan_inums]
|
||||
|
||||
print(f"\nOrphaned roots: {len(true_roots)}")
|
||||
print(f"{'inode':>12} {'parent':>12} {'status':>12} {'dtime':>12} reason")
|
||||
print('-' * 75)
|
||||
|
||||
with open(DEV, 'rb') as f:
|
||||
for inum, parent, reason in sorted(true_roots):
|
||||
try:
|
||||
idata, slot = read_inode(f, sb, gdt_data, inum)
|
||||
status = classify_inode(idata, slot)
|
||||
dtime = struct.unpack_from('<I', idata, slot + 20)[0]
|
||||
# Format dtime as human readable if set
|
||||
if dtime:
|
||||
import datetime
|
||||
dt = datetime.datetime.fromtimestamp(dtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
dt = 'never'
|
||||
except Exception:
|
||||
status, dt = 'unreadable', 'unknown'
|
||||
|
||||
print(f"{inum:>12} {parent:>12} {status:>12} {dt:>19} {reason}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
246
test/scan_inodes.py.bak
Normal file
246
test/scan_inodes.py.bak
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Scan ext4 filesystem for orphaned directory roots.
|
||||
Reads inode table directly using geometry from backup superblock.
|
||||
"""
|
||||
import struct, sys
|
||||
from collections import defaultdict
|
||||
|
||||
DEV = '/dev/dm-0'
|
||||
BLOCK = 4096
|
||||
BACKUP_SB_BLOCK = 32768
|
||||
|
||||
def read_at(f, offset, size):
|
||||
f.seek(offset)
|
||||
return f.read(size)
|
||||
|
||||
def parse_superblock(data):
|
||||
sb = {}
|
||||
sb['inodes_count'] = struct.unpack_from('<I', data, 0)[0]
|
||||
sb['blocks_count'] = struct.unpack_from('<I', data, 4)[0]
|
||||
sb['blocks_per_group'] = struct.unpack_from('<I', data, 32)[0]
|
||||
sb['inodes_per_group'] = struct.unpack_from('<I', data, 40)[0]
|
||||
sb['inode_size'] = struct.unpack_from('<H', data, 88)[0]
|
||||
sb['magic'] = struct.unpack_from('<H', data, 56)[0]
|
||||
sb['feature_incompat'] = struct.unpack_from('<I', data, 96)[0]
|
||||
sb['desc_size'] = struct.unpack_from('<H', data, 254)[0] or 32
|
||||
return sb
|
||||
|
||||
def parse_gdt_entry(data, offset, desc_size):
|
||||
"""Parse group descriptor - handles both 32 and 64-bit descriptors"""
|
||||
inode_table_lo = struct.unpack_from('<I', data, offset + 8)[0]
|
||||
if desc_size >= 64:
|
||||
inode_table_hi = struct.unpack_from('<I', data, offset + 40)[0]
|
||||
return inode_table_lo | (inode_table_hi << 32)
|
||||
return inode_table_lo
|
||||
|
||||
def parse_extent_tree(data, inode_offset):
|
||||
base = inode_offset + 40
|
||||
magic, entries, max_entries, depth = struct.unpack_from('<HHHH', data, base)
|
||||
|
||||
if magic != 0xF30A:
|
||||
return []
|
||||
|
||||
extents = []
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
ext_off = base + 12 + i * 12
|
||||
if ext_off + 12 > len(data):
|
||||
break
|
||||
# Correct layout: l_block(4) + ee_len(2) + ee_start_hi(2) + ee_start_lo(4)
|
||||
l_block = struct.unpack_from('<I', data, ext_off)[0]
|
||||
ee_len = struct.unpack_from('<H', data, ext_off + 4)[0]
|
||||
start_hi = struct.unpack_from('<H', data, ext_off + 6)[0]
|
||||
start_lo = struct.unpack_from('<I', data, ext_off + 8)[0]
|
||||
phys = (start_hi << 32) | start_lo
|
||||
if phys > 0:
|
||||
extents.append((l_block, phys, ee_len & 0x7FFF))
|
||||
return extents
|
||||
|
||||
def read_dir_entries(f, inode_data, inode_offset):
|
||||
"""Read directory entries using extent tree from inode data"""
|
||||
extents = parse_extent_tree(inode_data, inode_offset)
|
||||
entries = {}
|
||||
for _, phys_block, length in extents[:1]: # first extent is enough for . and ..
|
||||
try:
|
||||
data = read_at(f, phys_block * BLOCK, BLOCK)
|
||||
offset = 0
|
||||
while offset < BLOCK - 8:
|
||||
ino, rec_len, name_len, ftype = struct.unpack_from(
|
||||
'<IHBB', data, offset)
|
||||
if rec_len < 8 or offset + rec_len > BLOCK:
|
||||
break
|
||||
if ino != 0 and name_len > 0:
|
||||
name = data[offset+8:offset+8+name_len].decode(
|
||||
'utf-8', errors='replace')
|
||||
entries[name] = (ino, ftype)
|
||||
offset += rec_len
|
||||
except OSError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def parse_inode(data, offset):
|
||||
if len(data) - offset < 128:
|
||||
return None
|
||||
mode, uid, size_lo = struct.unpack_from('<HHI', data, offset)
|
||||
atime, ctime, mtime, dtime = struct.unpack_from('<IIII', data, offset + 8)
|
||||
links_count = struct.unpack_from('<H', data, offset + 26)[0]
|
||||
# block pointers start at offset 40, 60 bytes (12 direct + ind + dind + tind)
|
||||
block0 = struct.unpack_from('<I', data, offset + 40)[0]
|
||||
return {
|
||||
'mode': mode,
|
||||
'type': mode & 0xF000,
|
||||
'links': links_count,
|
||||
'ctime': ctime,
|
||||
'mtime': mtime,
|
||||
'block0': block0, # first direct block pointer
|
||||
}
|
||||
|
||||
def main():
|
||||
with open(DEV, 'rb') as f:
|
||||
# Read backup superblock (no +1024 offset for backup blocks)
|
||||
sb_data = read_at(f, BACKUP_SB_BLOCK * BLOCK, 1024)
|
||||
sb = parse_superblock(sb_data)
|
||||
# After parsing superblock, check feature flags
|
||||
|
||||
INCOMPAT_EXTENTS = 0x40
|
||||
uses_extents = sb['feature_incompat'] & INCOMPAT_EXTENTS
|
||||
print(f"Extent trees: {'yes' if uses_extents else 'no'}")
|
||||
|
||||
assert sb['magic'] == 0xef53, f"Bad SB magic: {sb['magic']:#x}"
|
||||
print(f"Geometry: {sb['blocks_per_group']} blk/grp, "
|
||||
f"{sb['inodes_per_group']} ino/grp, "
|
||||
f"inode_size={sb['inode_size']}, "
|
||||
f"desc_size={sb['desc_size']}")
|
||||
|
||||
num_groups = (sb['blocks_count'] + sb['blocks_per_group'] - 1) \
|
||||
// sb['blocks_per_group']
|
||||
print(f"Total groups: {num_groups}, scanning from group 13+")
|
||||
|
||||
# Read GDT from backup location (block after backup SB)
|
||||
gdt_data = read_at(f, (BACKUP_SB_BLOCK + 1) * BLOCK,
|
||||
num_groups * sb['desc_size'])
|
||||
|
||||
# Map: inode_num -> (parent_inode, group, name)
|
||||
# We collect (dot_inode, dotdot_inode) for every dir we find
|
||||
dir_parents = {} # inode -> parent_inode
|
||||
all_dirs = set()
|
||||
|
||||
for grp in range(13, num_groups):
|
||||
inode_table_block = parse_gdt_entry(
|
||||
gdt_data, grp * sb['desc_size'], sb['desc_size'])
|
||||
if inode_table_block == 0:
|
||||
continue
|
||||
|
||||
inodes_per_block = BLOCK // sb['inode_size']
|
||||
num_inode_blocks = (sb['inodes_per_group'] * sb['inode_size']
|
||||
+ BLOCK - 1) // BLOCK
|
||||
|
||||
for blk_off in range(num_inode_blocks):
|
||||
try:
|
||||
idata = read_at(f,
|
||||
(inode_table_block + blk_off) * BLOCK, BLOCK)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for slot in range(inodes_per_block):
|
||||
ino_off = slot * sb['inode_size']
|
||||
ino = parse_inode(idata, ino_off)
|
||||
if ino is None:
|
||||
continue
|
||||
if ino['type'] != 0x4000: # S_IFDIR
|
||||
continue
|
||||
if ino['links'] == 0:
|
||||
continue
|
||||
|
||||
abs_inum = (grp * sb['inodes_per_group']
|
||||
+ blk_off * inodes_per_block
|
||||
+ slot + 1)
|
||||
all_dirs.add(abs_inum)
|
||||
# Add this debug block right after all_dirs.add(abs_inum)
|
||||
# Just for the first 5 dirs found, dump raw extent header
|
||||
if len(all_dirs) <= 5:
|
||||
base = ino_off + 40
|
||||
raw = idata[base:base+24]
|
||||
magic, entries_cnt, max_e, depth = struct.unpack_from('<HHHH', raw, 0)
|
||||
print(f"\nDEBUG inode {abs_inum} grp={grp}:")
|
||||
print(f" raw bytes: {raw.hex()}")
|
||||
print(f" extent header: magic={magic:#06x} entries={entries_cnt} depth={depth}")
|
||||
if len(raw) >= 24:
|
||||
l_block, len_blks, start_hi, start_lo = struct.unpack_from('<IIHH', raw, 12)
|
||||
phys = (start_hi << 32) | start_lo
|
||||
print(f" first extent: l_block={l_block} phys={phys} len={len_blks}")
|
||||
# Try reading what's at that block
|
||||
if phys > 0:
|
||||
try:
|
||||
ddata = read_at(f, phys * BLOCK, 32)
|
||||
print(f" block {phys} first 32 bytes: {ddata.hex()}")
|
||||
# Check if it looks like a dir entry
|
||||
ino2, rec2, nlen2, ft2 = struct.unpack_from('<IHBB', ddata, 0)
|
||||
print(f" as dir entry: inode={ino2} rec_len={rec2} name_len={nlen2}")
|
||||
except OSError as e:
|
||||
print(f" read error: {e}")
|
||||
|
||||
entries = read_dir_entries(f, idata, ino_off)
|
||||
|
||||
dot = entries.get('.', (None,))[0]
|
||||
dotdot = entries.get('..', (None,))[0]
|
||||
|
||||
if dot == abs_inum and dotdot is not None:
|
||||
dir_parents[abs_inum] = dotdot
|
||||
|
||||
if grp % 100 == 0:
|
||||
print(f" scanned group {grp}/{num_groups}, "
|
||||
f"dirs so far: {len(all_dirs)}",
|
||||
end='\r', flush=True)
|
||||
|
||||
print(f"\nTotal dirs found: {len(all_dirs)}")
|
||||
print(f"Dirs with readable . and ..: {len(dir_parents)}")
|
||||
|
||||
FIRST_GOOD_INODE = 13 * 8192 # first inode in group 13
|
||||
|
||||
orphan_roots = []
|
||||
for inum, parent in dir_parents.items():
|
||||
if parent == inum:
|
||||
orphan_roots.append((inum, parent, 'self-referential'))
|
||||
elif parent < FIRST_GOOD_INODE:
|
||||
# parent is in zeroed region - this is a detached root
|
||||
orphan_roots.append((inum, parent, 'parent-in-zeroed-region'))
|
||||
elif parent not in all_dirs:
|
||||
orphan_roots.append((inum, parent, 'parent-missing'))
|
||||
|
||||
print(f"\nOrphaned roots: {len(orphan_roots)}")
|
||||
print(f"{'inode':>12} {'parent':>12} reason")
|
||||
print('-' * 45)
|
||||
for inum, parent, reason in sorted(orphan_roots):
|
||||
print(f"{inum:>12} {parent:>12} {reason}")
|
||||
# Add this after the orphan_roots list is built
|
||||
|
||||
# Build set of all orphaned inodes
|
||||
orphan_inums = {inum for inum, parent, reason in orphan_roots}
|
||||
|
||||
# True roots: orphans whose parent is not itself an orphan
|
||||
true_roots = [(inum, parent, reason)
|
||||
for inum, parent, reason in orphan_roots
|
||||
if parent not in orphan_inums]
|
||||
|
||||
print(f"\nTrue detached tree roots: {len(true_roots)}")
|
||||
print(f"{'inode':>12} {'parent':>12} reason")
|
||||
print('-' * 55)
|
||||
for inum, parent, reason in sorted(true_roots):
|
||||
# Try to get first few dir entries to identify the tree
|
||||
with open(DEV, 'rb') as f:
|
||||
grp = (inum - 1) // sb['inodes_per_group']
|
||||
local_idx = (inum - 1) % sb['inodes_per_group']
|
||||
inode_table_block = parse_gdt_entry(
|
||||
gdt_data, grp * sb['desc_size'], sb['desc_size'])
|
||||
blk_off = (local_idx * sb['inode_size']) // BLOCK
|
||||
slot = (local_idx * sb['inode_size']) % BLOCK
|
||||
idata = read_at(f, (inode_table_block + blk_off) * BLOCK, BLOCK)
|
||||
entries = read_dir_entries(f, idata, slot)
|
||||
# Show entries excluding . and ..
|
||||
names = [k for k in entries if k not in ('.', '..')][:5]
|
||||
print(f"{inum:>12} {parent:>12} {names}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
127
test/scan_inodes_for_specific_names.py
Normal file
127
test/scan_inodes_for_specific_names.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
import struct, os
|
||||
|
||||
DEVICE = '/dev/nbd0'
|
||||
BSIZE = 4096
|
||||
IPG = 8192
|
||||
INODE_SZ = 256
|
||||
NUM_GROUPS = 35728
|
||||
MIN_GROUP = 13
|
||||
|
||||
TARGETS = [
|
||||
b'pterodactyl',
|
||||
b'mysql',
|
||||
b'www',
|
||||
b'log',
|
||||
b'docker',
|
||||
b'nginx',
|
||||
b'apache2',
|
||||
b'archives',
|
||||
b'wings',
|
||||
b'grub2-efi.cfg',
|
||||
b'commons-codec',
|
||||
]
|
||||
|
||||
def is_valid_dirent(block, off, name):
|
||||
if off + 8 + len(name) > BSIZE: return False
|
||||
inode = struct.unpack_from('<I', block, off)[0]
|
||||
rec_len = struct.unpack_from('<H', block, off+4)[0]
|
||||
name_len = block[off+6]
|
||||
ftype = block[off+7]
|
||||
if not (10 < inode < 500_000_000): return False
|
||||
if name_len != len(name): return False
|
||||
if rec_len < 8+name_len or rec_len > BSIZE or rec_len%4 != 0: return False
|
||||
if ftype not in (1,2,7): return False
|
||||
if block[off+8:off+8+name_len] != name: return False
|
||||
pad = off+8+name_len
|
||||
if pad < BSIZE and block[pad] != 0: return False
|
||||
return True
|
||||
|
||||
def parse_extents(inode_data):
|
||||
blocks = []
|
||||
magic = struct.unpack_from('<H', inode_data, 40)[0]
|
||||
if magic != 0xf30a:
|
||||
return blocks
|
||||
depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
entries = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
off = 52 + i*12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
ee_start = (ee_hi << 32) | ee_lo
|
||||
if ee_len > 1024: continue
|
||||
for b in range(min(ee_len, 8)):
|
||||
blocks.append(ee_start + b)
|
||||
return blocks
|
||||
|
||||
results = {}
|
||||
|
||||
print(f'Device: {DEVICE}')
|
||||
print(f'Scanning groups {MIN_GROUP} to {NUM_GROUPS-1}...')
|
||||
print()
|
||||
|
||||
with open(DEVICE, 'rb', buffering=0) as f:
|
||||
for group in range(MIN_GROUP, NUM_GROUPS):
|
||||
it_block = 1070 + group * 512
|
||||
try:
|
||||
f.seek(it_block * BSIZE)
|
||||
inode_table = f.read(IPG * INODE_SZ)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for idx in range(IPG):
|
||||
inode_data = inode_table[idx*INODE_SZ:(idx+1)*INODE_SZ]
|
||||
if not any(inode_data):
|
||||
continue
|
||||
|
||||
mode = struct.unpack_from('<H', inode_data, 0)[0]
|
||||
links = struct.unpack_from('<H', inode_data, 26)[0]
|
||||
|
||||
if (mode & 0xf000) != 0x4000:
|
||||
continue
|
||||
if links < 2:
|
||||
continue
|
||||
|
||||
inode_num = group * IPG + idx + 1
|
||||
blocks = parse_extents(inode_data)
|
||||
|
||||
for blk in blocks:
|
||||
try:
|
||||
f.seek(blk * BSIZE)
|
||||
blk_data = f.read(BSIZE)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for target in TARGETS:
|
||||
if target not in blk_data:
|
||||
continue
|
||||
for off in range(0, BSIZE-8):
|
||||
if blk_data[off+8:off+8+len(target)] != target:
|
||||
continue
|
||||
if is_valid_dirent(blk_data, off, target):
|
||||
child_ino = struct.unpack_from('<I',blk_data,off)[0]
|
||||
ftype = blk_data[off+7]
|
||||
child_grp = (child_ino-1)//IPG
|
||||
key = (target.decode(), child_ino)
|
||||
if key not in results:
|
||||
results[key] = (inode_num, ftype, child_grp)
|
||||
status = 'INTACT' if child_grp>=13 else 'LOST'
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f'[{status}] {target.decode()!r:15s} '
|
||||
f'child={child_ino:10d} '
|
||||
f'parent={inode_num:10d} '
|
||||
f'type={tname}', flush=True)
|
||||
|
||||
if group % 1000 == 0:
|
||||
print(f' Group {group}/{NUM_GROUPS}...', flush=True)
|
||||
|
||||
print()
|
||||
print('=== SUMMARY ===')
|
||||
for (name, child_ino), (parent_ino, ftype, grp) in sorted(results.items()):
|
||||
status = 'INTACT' if grp >= 13 else 'LOST'
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f'[{status}] {name!r:15s} child={child_ino} '
|
||||
f'parent={parent_ino} type={tname}')
|
||||
|
||||
150
test/scan_inodes_for_specific_names_raw.py
Normal file
150
test/scan_inodes_for_specific_names_raw.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Scan inode tables directly for directory inodes,
|
||||
then read their data blocks looking for target names.
|
||||
Much faster than full disk scan.
|
||||
"""
|
||||
import struct, os
|
||||
|
||||
CHUNK = 128 * 512
|
||||
LV_START = 5120000 * 512
|
||||
BSIZE = 4096
|
||||
DISKS = ['/dev/sda', '/dev/sde', '/dev/sdd', '/dev/sdc']
|
||||
IPG = 8192
|
||||
INODE_SZ = 256
|
||||
BPG = 32768
|
||||
NUM_GROUPS = 35728
|
||||
MIN_GROUP = 13 # groups 0-12 are zeroed
|
||||
|
||||
TARGETS = [
|
||||
b'pterodactyl',
|
||||
b'var',
|
||||
b'mysql',
|
||||
b'www',
|
||||
b'log',
|
||||
b'docker',
|
||||
b'nginx',
|
||||
b'apache2',
|
||||
b'www-data',
|
||||
]
|
||||
|
||||
def v_to_p(virt_byte):
|
||||
"""Virtual byte offset to physical (disk 0) byte offset."""
|
||||
group = virt_byte // (5 * CHUNK)
|
||||
in_group = virt_byte % (5 * CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
if chunk_idx == 4:
|
||||
return None
|
||||
return LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
|
||||
def read_virt(f, virt_byte, length):
|
||||
"""Read from virtual address space via disk 0."""
|
||||
phys = v_to_p(virt_byte)
|
||||
if phys is None:
|
||||
return b'\x00' * length
|
||||
f.seek(phys)
|
||||
return f.read(length)
|
||||
|
||||
def is_valid_dirent(block, off, name):
|
||||
if off + 8 + len(name) > BSIZE: return False
|
||||
inode = struct.unpack_from('<I', block, off)[0]
|
||||
rec_len = struct.unpack_from('<H', block, off+4)[0]
|
||||
name_len = block[off+6]
|
||||
ftype = block[off+7]
|
||||
if not (10 < inode < 500_000_000): return False
|
||||
if name_len != len(name): return False
|
||||
if rec_len < 8+name_len or rec_len > BSIZE or rec_len%4 != 0: return False
|
||||
if ftype not in (1,2,7): return False
|
||||
if block[off+8:off+8+name_len] != name: return False
|
||||
pad = off+8+name_len
|
||||
if pad < BSIZE and block[pad] != 0: return False
|
||||
return True
|
||||
|
||||
def parse_extents(inode_data):
|
||||
"""Get list of physical block numbers from extent tree."""
|
||||
blocks = []
|
||||
magic = struct.unpack_from('<H', inode_data, 40)[0]
|
||||
if magic != 0xf30a:
|
||||
return blocks
|
||||
depth = struct.unpack_from('<H', inode_data, 46)[0]
|
||||
entries = struct.unpack_from('<H', inode_data, 42)[0]
|
||||
if depth == 0:
|
||||
for i in range(min(entries, 4)):
|
||||
off = 52 + i*12
|
||||
ee_len = struct.unpack_from('<H', inode_data, off+4)[0]
|
||||
ee_hi = struct.unpack_from('<H', inode_data, off+6)[0]
|
||||
ee_lo = struct.unpack_from('<I', inode_data, off+8)[0]
|
||||
ee_start = (ee_hi << 32) | ee_lo
|
||||
for b in range(min(ee_len, 8)): # max 8 blocks per dir
|
||||
blocks.append(ee_start + b)
|
||||
return blocks
|
||||
|
||||
results = {}
|
||||
|
||||
print('Scanning inode tables directly...')
|
||||
print(f'Groups to scan: {MIN_GROUP} to {NUM_GROUPS-1}')
|
||||
print()
|
||||
|
||||
with open('/dev/sda', 'rb', buffering=0) as f:
|
||||
for group in range(MIN_GROUP, NUM_GROUPS):
|
||||
# Inode table for group N is at block 1070 + N*512
|
||||
it_block = 1070 + group * 512
|
||||
it_virt = it_block * BSIZE
|
||||
|
||||
# Read entire inode table for this group
|
||||
inode_table = read_virt(f, it_virt, IPG * INODE_SZ)
|
||||
|
||||
for idx in range(IPG):
|
||||
inode_data = inode_table[idx*INODE_SZ:(idx+1)*INODE_SZ]
|
||||
if not any(inode_data):
|
||||
continue
|
||||
|
||||
mode = struct.unpack_from('<H', inode_data, 0)[0]
|
||||
links = struct.unpack_from('<H', inode_data, 26)[0]
|
||||
size = struct.unpack_from('<I', inode_data, 4)[0]
|
||||
|
||||
# Check if directory: mode & 0xf000 == 0x4000
|
||||
if (mode & 0xf000) != 0x4000:
|
||||
continue
|
||||
if links < 2:
|
||||
continue
|
||||
|
||||
inode_num = group * IPG + idx + 1
|
||||
|
||||
# Read directory data blocks and scan for targets
|
||||
blocks = parse_extents(inode_data)
|
||||
for blk in blocks:
|
||||
blk_virt = blk * BSIZE
|
||||
blk_data = read_virt(f, blk_virt, BSIZE)
|
||||
|
||||
for target in TARGETS:
|
||||
if target not in blk_data:
|
||||
continue
|
||||
for off in range(0, BSIZE-8):
|
||||
if blk_data[off+8:off+8+len(target)] != target:
|
||||
continue
|
||||
if is_valid_dirent(blk_data, off, target):
|
||||
child_ino = struct.unpack_from('<I',blk_data,off)[0]
|
||||
ftype = blk_data[off+7]
|
||||
child_grp = (child_ino-1)//IPG
|
||||
key = (target.decode(), child_ino)
|
||||
if key not in results:
|
||||
results[key] = (inode_num, ftype, child_grp)
|
||||
status = 'INTACT' if child_grp>=13 else 'LOST'
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f'[{status}] {target.decode()!r:15s} '
|
||||
f'child_inode={child_ino:10d} '
|
||||
f'parent_inode={inode_num:10d} '
|
||||
f'type={tname}')
|
||||
|
||||
if group % 1000 == 0:
|
||||
print(f' Group {group}/{NUM_GROUPS}...', flush=True)
|
||||
|
||||
print()
|
||||
print('=== SUMMARY ===')
|
||||
for (name, child_ino), (parent_ino, ftype, grp) in sorted(results.items()):
|
||||
status = 'INTACT' if grp >= 13 else 'LOST'
|
||||
tname = {1:'file',2:'dir',7:'link'}.get(ftype,'?')
|
||||
print(f'[{status}] {name!r:15s} '
|
||||
f'child={child_ino} parent={parent_ino} type={tname}')
|
||||
12
test/setup.sh
Normal file
12
test/setup.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
apt update
|
||||
apt install -y nbd-server nbd-client python3-libnbd testdisk sleuthkit python3-crcmod
|
||||
mkdir /mnt/recovered
|
||||
mdadm --stop /dev/md126
|
||||
mdadm --stop /dev/md127
|
||||
mdadm --build /dev/md0 --level=0 --raid-devices=4 \
|
||||
--chunk=64 /dev/sda /dev/sde /dev/sdd /dev/sdc
|
||||
python3.12 build_merged.py
|
||||
python3.12 nbd_server_v9.py &
|
||||
nbd-client 127.0.0.1 10809 /dev/nbd0 -N ""
|
||||
|
||||
|
||||
25
test/test.py
Normal file
25
test/test.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Run this standalone first to verify the block is readable and contains dir entries
|
||||
import struct
|
||||
|
||||
BLOCK = 4096
|
||||
DEV = '/dev/dm-0'
|
||||
|
||||
phys = 3153952 # from first debug inode
|
||||
with open(DEV, 'rb') as f:
|
||||
f.seek(phys * BLOCK)
|
||||
data = f.read(BLOCK)
|
||||
|
||||
print(f"Read {len(data)} bytes")
|
||||
print(f"First 32 bytes: {data[:32].hex()}")
|
||||
|
||||
# Try parsing as dir entries
|
||||
offset = 0
|
||||
while offset < 128:
|
||||
ino, rec_len, name_len, ftype = struct.unpack_from('<IHBB', data, offset)
|
||||
print(f" offset={offset}: inode={ino} rec_len={rec_len} name_len={name_len} ftype={ftype}")
|
||||
if rec_len < 8:
|
||||
break
|
||||
if name_len > 0:
|
||||
name = data[offset+8:offset+8+name_len].decode('utf-8', errors='replace')
|
||||
print(f" name='{name}'")
|
||||
offset += rec_len
|
||||
52
test/testoffset.sh
Normal file
52
test/testoffset.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
python3 -c "
|
||||
import struct
|
||||
|
||||
# Read what's actually at the start of our NBD device
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
# Block 0 (should be boot block, all zeros for non-bootable)
|
||||
block0 = f.read(4096)
|
||||
# Superblock at byte 1024
|
||||
f.seek(1024)
|
||||
sb = f.read(256)
|
||||
|
||||
# Check block 0
|
||||
nonzero = sum(1 for b in block0 if b != 0)
|
||||
print(f'Block 0 non-zero bytes: {nonzero} (should be 0 for ext4)')
|
||||
print(f'Block 0 first 16: {block0[:16].hex()}')
|
||||
|
||||
# Check superblock
|
||||
magic = struct.unpack_from('<H', sb, 56)[0]
|
||||
uuid = sb[104:120].hex()
|
||||
first_data_block = struct.unpack_from('<I', sb, 20)[0]
|
||||
blocks_per_group = struct.unpack_from('<I', sb, 40)[0]
|
||||
print(f'SB magic: 0x{magic:04x} (want 0xef53)')
|
||||
print(f'SB uuid: {uuid}')
|
||||
print(f'first_data_block: {first_data_block} (0 for bsize>1024, 1 for bsize=1024)')
|
||||
print(f'blocks_per_group: {blocks_per_group}')
|
||||
|
||||
# The GDT should be at block 1 = byte 4096
|
||||
# But if first_data_block=1, GDT is at block 2 = byte 8192
|
||||
f2 = open('/dev/nbd0','rb')
|
||||
f2.seek(4096)
|
||||
gdt0 = f2.read(64)
|
||||
bb = struct.unpack_from('<I',gdt0,0)[0]
|
||||
ib = struct.unpack_from('<I',gdt0,4)[0]
|
||||
it = struct.unpack_from('<I',gdt0,8)[0]
|
||||
cs = struct.unpack_from('<H',gdt0,30)[0]
|
||||
print(f'At byte 4096 (block 1): bb={bb} ib={ib} it={it} csum=0x{cs:04x}')
|
||||
|
||||
f2.seek(8192)
|
||||
gdt0b = f2.read(64)
|
||||
bb = struct.unpack_from('<I',gdt0b,0)[0]
|
||||
ib = struct.unpack_from('<I',gdt0b,4)[0]
|
||||
it = struct.unpack_from('<I',gdt0b,8)[0]
|
||||
cs = struct.unpack_from('<H',gdt0b,30)[0]
|
||||
print(f'At byte 8192 (block 2): bb={bb} ib={ib} it={it} csum=0x{cs:04x}')
|
||||
|
||||
# Check what libext2fs would see as the device size
|
||||
import os
|
||||
f2.seek(0,2)
|
||||
size = f2.tell()
|
||||
print(f'NBD device size: {size} bytes = {size//4096} blocks')
|
||||
f2.close()
|
||||
"
|
||||
25
test/testsize.sh
Normal file
25
test/testsize.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
# What size does the kernel think nbd0 is?
|
||||
blockdev --getsize64 /dev/nbd0
|
||||
blockdev --getsz /dev/nbd0
|
||||
|
||||
# What does the superblock say?
|
||||
python3 -c "
|
||||
import struct
|
||||
with open('/dev/nbd0','rb') as f:
|
||||
f.seek(1024)
|
||||
sb = f.read(256)
|
||||
blocks_lo = struct.unpack_from('<I',sb,4)[0]
|
||||
blocks_hi = struct.unpack_from('<I',sb,336)[0]
|
||||
total = (blocks_hi<<32)|blocks_lo
|
||||
bsize = 4096
|
||||
print(f'SB block count: {total}')
|
||||
print(f'SB filesystem size: {total*bsize} bytes')
|
||||
print(f'NBD device size: ', end='')
|
||||
import subprocess
|
||||
r = subprocess.run(['blockdev','--getsize64','/dev/nbd0'],
|
||||
capture_output=True,text=True)
|
||||
nbd_size = int(r.stdout.strip())
|
||||
print(nbd_size)
|
||||
print(f'Match: {total*bsize == nbd_size}')
|
||||
print(f'Difference: {abs(total*bsize - nbd_size)} bytes')
|
||||
"
|
||||
47713
test/tree.txt
Normal file
47713
test/tree.txt
Normal file
File diff suppressed because it is too large
Load Diff
49802
test/true_roots.txt
Normal file
49802
test/true_roots.txt
Normal file
File diff suppressed because it is too large
Load Diff
60
test/tt.sh
Normal file
60
test/tt.sh
Normal file
@@ -0,0 +1,60 @@
|
||||
python3 -c "
|
||||
import struct
|
||||
|
||||
CHUNK = 128*512
|
||||
LV_START = 5120000*512
|
||||
BSIZE = 4096
|
||||
GDT_ENTRY = 64
|
||||
BPG = 32768
|
||||
|
||||
def raw_read(virt_offset, length):
|
||||
result = bytearray(length)
|
||||
pos = virt_offset
|
||||
remaining = length
|
||||
with open('/dev/md0','rb') as f:
|
||||
while remaining > 0:
|
||||
group = pos // (5*CHUNK)
|
||||
in_group = pos % (5*CHUNK)
|
||||
chunk_idx = in_group // CHUNK
|
||||
intra = in_group % CHUNK
|
||||
seg_len = min(CHUNK-intra, remaining)
|
||||
dst_off = pos - virt_offset
|
||||
if chunk_idx != 4:
|
||||
phys = LV_START + group*4*CHUNK + chunk_idx*CHUNK + intra
|
||||
f.seek(phys)
|
||||
data = f.read(seg_len)
|
||||
result[dst_off:dst_off+len(data)] = data
|
||||
pos += seg_len
|
||||
remaining -= seg_len
|
||||
return bytes(result)
|
||||
|
||||
# Read primary GDT (at block 1 = byte 4096)
|
||||
# and backup GDT (at block group 1 start + 1 block)
|
||||
# Group 1 starts at block 32768, GDT backup at block 32769 = byte 32769*4096
|
||||
|
||||
# Read 1000 entries from primary GDT
|
||||
primary_gdt = bytearray(raw_read(BSIZE, 1000 * GDT_ENTRY))
|
||||
|
||||
# Read 1000 entries from backup GDT at group 1
|
||||
backup_start = (BPG + 1) * BSIZE # block 32769
|
||||
backup_gdt = raw_read(backup_start, 1000 * GDT_ENTRY)
|
||||
|
||||
print('Comparing primary vs backup GDT entries:')
|
||||
mismatches = 0
|
||||
zeros = 0
|
||||
for i in range(1000):
|
||||
p = primary_gdt[i*GDT_ENTRY:(i+1)*GDT_ENTRY]
|
||||
b = backup_gdt[i*GDT_ENTRY:(i+1)*GDT_ENTRY]
|
||||
p_bb = struct.unpack_from('<I',p,0)[0]
|
||||
b_bb = struct.unpack_from('<I',b,0)[0]
|
||||
if p == bytes(GDT_ENTRY):
|
||||
zeros += 1
|
||||
elif p != b:
|
||||
mismatches += 1
|
||||
if mismatches <= 5:
|
||||
print(f' Group {i}: primary bb={p_bb} vs backup bb={b_bb}')
|
||||
|
||||
print(f'Zero entries in primary: {zeros}/1000')
|
||||
print(f'Mismatches: {mismatches}/1000')
|
||||
print(f'Backup GDT group 0: bb={struct.unpack_from(\"<I\",backup_gdt,0)[0]}')
|
||||
"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user