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(' 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(' 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(' 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