#!/bin/sh # LuCI RPCD Backend for luci-app-diskbox # Copyright (C) 2026 permails # Licensed under the GNU General Public License v3.0 . /usr/share/libubox/jshn.sh # --- Per-process caches ----------------------------------------------------- # rpcd runs every RPC in a fresh process, so a process-global cache is # inherently per-request: built on first use, never spanning calls. We cache # facts that are invariant within a single RPC (a device's fstype, its parent # disk, whether it is external) so the listing loop and the guard chain do not # each re-run blkid / get_parent_disk / is_external_device for the same device. # A stale entry can only ever surface in a single in-flight call after a # format, and the brick-critical guard decision is always made *before* any # format - so the cache never changes a protection outcome. # Entries are stored as |=|=... The "=" boundary keeps # a key from colliding with a value that happens to name the same device. _FS_CACHE="" # "|=|=..." _PD_CACHE="" # "|=|=..." _EXT_CACHE="" # "|=0|=1..." # dev_fs_type : device filesystem type, cached per process (avoids a # redundant blkid when both the listing loop and the guard chain probe it). dev_fs_type() { local d="$1" d=${d#/dev/} local found="" val="" case "$_FS_CACHE" in *"|$d="*) found=1; val=${_FS_CACHE#*|$d=}; val=${val%%|*} ;; esac [ -n "$found" ] && { printf '%s' "$val"; return; } val=$(blkid -o value -s TYPE "/dev/$d" 2>/dev/null | head -n1) _FS_CACHE="${_FS_CACHE}|${d}=${val}" printf '%s' "$val" } # Helper: format bytes to human readable string (e.g. 1.23 GB) format_bytes() { local bytes="$1" [ -z "$bytes" ] && echo "-" && return awk -v b="$bytes" 'BEGIN { split("B KB MB GB TB PB", u, " "); i = 1; while (b >= 1024 && i < 6) { b = b / 1024; i++; } if (i == 1) printf "%.0f %s", b, u[i]; else printf "%.2f %s", b, u[i]; }' } # Helper: check if device or partition is in use (mounted, swap, raid) check_inuse() { local dev="$1" local bname=$(basename "$dev") if grep -q "^/dev/${bname}[[:space:]]" /proc/mounts 2>/dev/null; then echo "1" return fi if grep -q "^/dev/${bname}[[:space:]]" /proc/swaps 2>/dev/null; then echo "1" return fi if [ -f /proc/mdstat ] && grep -q "${bname}" /proc/mdstat 2>/dev/null; then echo "1" return fi echo "0" } # Helper: get mount point for partition get_mount_point() { local dev="$1" local bname=$(basename "$dev") local mnt=$(awk -v d="/dev/$bname" '$1==d {printf "%s ", $2}' /proc/mounts 2>/dev/null) if [ -n "$mnt" ]; then echo "$mnt" | sed 's/ $//' return fi if grep -q "^/dev/${bname}[[:space:]]" /proc/swaps 2>/dev/null; then echo "swap" return fi if [ -f /proc/mdstat ]; then local md=$(awk -v d="$bname" '$0 ~ d {gsub(/[[\]:]/,""); print $1}' /proc/mdstat 2>/dev/null | head -n1) if [ -n "$md" ]; then echo "Raid Member: $md" return fi fi echo "-" } # Helper: run smartctl with fallback device types run_smartctl() { local dev="$1" local opts="$2" which smartctl >/dev/null 2>&1 || return local devtype="" case "$dev" in nvme*) devtype="nvme" ;; esac if [ -n "$devtype" ]; then smartctl -d "$devtype" $opts "/dev/$dev" 2>/dev/null && return fi for t in "" "sat" "scsi" "nvme"; do local res="" if [ -z "$t" ]; then res=$(smartctl $opts "/dev/$dev" 2>&1) else res=$(smartctl -d "$t" $opts "/dev/$dev" 2>&1) fi if echo "$res" | grep -qE "START OF SMART DATA SECTION|START OF READ SMART DATA SECTION|NVMe Version:|SMART Health Status:|Device is in [A-Z]+ mode"; then echo "$res" return fi done smartctl $opts "/dev/$dev" 2>&1 } # Methods # ===== BEGIN SYSTEM-DISK GUARD v5 (hotpatch 2026-09-03) ===== # v5.2: multi-signal system-partition classification (cross-SoC, FIT-robust) # hard (code:2) : live base system (mounted at / or /rom, squashfs) and # small firmware-label partitions on the root disk # soft (code:3) : mounted or filesystem-bearing partitions on internal # disks (incl. /overlay = factory-reset level warning); # unmounted small partitions on the root disk (A/B slots) # data space : ANY partition > 4GiB is user data, never "system" # (128G eMMC: 2G system + 126G data area, data is often # rootfs_data mounted at /overlay) # system disk : disk holding the root= partition (fallback: block device # backing / or /overlay*); mktable hard-blocked, create # allowed (unallocated space only), remove per-partition # is_external_device: returns 0 if external (USB), 1 if internal. # USB is detected via the sysfs device link, so x86 internal SATA (sd*) # and SoC internal NVMe are correctly treated as internal. is_external_device() { local bname=$(basename "$1") # Memoize per process: a disk's bus type is invariant within a call, and # every partition on it re-asks the same question from the guard chain. local found="" ext="" case "$_EXT_CACHE" in *"|$bname="*) found=1; ext=${_EXT_CACHE#*|$bname=}; ext=${ext%%|*} ;; esac if [ -n "$found" ]; then [ "$ext" = "1" ] && return 0 return 1 fi local link=$(readlink -f "/sys/block/$bname/device" 2>/dev/null) case "$link" in *usb*) ext=1 ;; *) ext=0 ;; esac _EXT_CACHE="${_EXT_CACHE}|${bname}=${ext}" [ "$ext" = "1" ] && return 0 return 1 } # get_parent_disk: whole-disk name for a partition; the name itself for a disk. # A partition's sysfs dir contains a file named ".." holding the disk name - # works whether or not partitions are listed in /sys/block (MTK FIT images # list them only under the disk dir). get_parent_disk() { local bname=$(basename "$1") # Memoize per process: a partition's parent disk is invariant within a call, # and the guard chain asks for the same parent up to three times per part. local found="" val="" case "$_PD_CACHE" in *"|$bname="*) found=1; val=${_PD_CACHE#*|$bname=}; val=${val%%|*} ;; esac [ -n "$found" ] && { printf '%s' "$val"; return; } if [ -f "/sys/block/$bname/.." ]; then val=$(head -n1 "/sys/block/$bname/.." 2>/dev/null) _PD_CACHE="${_PD_CACHE}|${bname}=${val}" printf '%s' "$val" return fi [ -e "/sys/block/$bname" ] && { _PD_CACHE="${_PD_CACHE}|${bname}=${bname}"; printf '%s' "$bname"; return; } for d in /sys/block/*/; do if [ -e "${d%/}/$bname" ]; then val=$(basename "$d") _PD_CACHE="${_PD_CACHE}|${bname}=${val}" printf '%s' "$val" return fi done val=$(lsblk -no PKNAME "/dev/$bname" 2>/dev/null | head -n1) _PD_CACHE="${_PD_CACHE}|${bname}=${val}" printf '%s' "$val" } # get_root_disk: the system disk. Resolution order: # 1) root= cmdline device, if it is a real sd/mmcblk/nvme/vd disk # 2) block device backing / or /overlay*, if a real disk # 3) FIT fallback: the internal (non-usb) disk. On MTK FIT images # root=/dev/fit0 is a virtual platform/fitblk device (not a block # partition), so fall back to the physical internal disk it lives on. get_root_disk() { # Invariant per process (one rpcd invocation = one process); cache the answer # so per-partition callers (partition_is_boot / disk_is_root_disk) don't # re-derive it every time. [ -n "${_ROOT_DISK+x}" ] && { printf '%s' "$_ROOT_DISK"; return; } _ROOT_DISK=$(get_root_disk_impl) printf '%s' "$_ROOT_DISK" } get_root_disk_impl() { local rootdev=$(grep -o 'root=/dev/[^ ]*' /proc/cmdline 2>/dev/null | sed 's|root=/dev/||' | head -n1) if [ -n "$rootdev" ]; then local rd=$(get_parent_disk "$rootdev") case "$rd" in sd*|mmcblk*|nvme*|vd*) echo "$rd"; return ;; esac fi local dev=$(awk '$2=="/" || index($2, "/overlay")==1 { if (index($1, "/dev/")==1) { s=$1; sub(/^\/dev\//, "", s); print s; exit } }' /proc/mounts 2>/dev/null) if [ -n "$dev" ]; then local rd=$(get_parent_disk "$dev") case "$rd" in sd*|mmcblk*|nvme*|vd*) echo "$rd"; return ;; esac fi # FIT fallback: the internal (non-usb) disk for d in /sys/block/sd* /sys/block/mmcblk* /sys/block/nvme* /sys/block/vd*; do [ -e "$d/device" ] || continue local bn=$(basename "$d") case "$bn" in *boot*) continue ;; esac is_external_device "$bn" || { echo "$bn"; return; } done } # is_live_root_mount: returns 0 if the device is the live base system, # i.e. mounted at / (real-fs root) or /rom (squashfs lower of overlayfs). # Wiping these bricks the device - size-independent, checked first. is_live_root_mount() { local bname=$(basename "$1") awk -v d="/dev/$bname" '$1==d && ($2=="/" || $2=="/rom") { found=1; exit } END { exit (found ? 0 : 1) }' /proc/mounts 2>/dev/null } # is_overlay_mount: returns 0 if the device is mounted at /overlay* # (rootfs_data: user data + config; wiping = factory reset, not brick) is_overlay_mount() { local bname=$(basename "$1") awk -v d="/dev/$bname" '$1==d && index($2, "/overlay")==1 { found=1; exit } END { exit (found ? 0 : 1) }' /proc/mounts 2>/dev/null } # part_large_enough: returns 0 if the block device exceeds 4 GiB (awk math, 32-bit safe) # Size file: top-level /sys/block//size, or under the parent disk dir # (/sys/block///size) on layouts where partitions are not # listed in /sys/block (e.g. MTK FIT eMMC). sysfs size is always 512-byte sectors. part_large_enough() { local bname=$(basename "$1") local parent=$(get_parent_disk "$bname") local szpath="/sys/block/$bname/size" [ "$parent" != "$bname" ] && szpath="/sys/block/$parent/$bname/size" [ -e "$szpath" ] || szpath="/sys/block/$bname/size" local sec=$(cat "$szpath" 2>/dev/null || echo 0) case "$sec" in ''|*[!0-9]*) sec=0 ;; esac awk -v s="$sec" 'BEGIN { exit ((s*512 > 4294967296) ? 0 : 1) }' } # disk_is_root_disk: returns 0 if this is the system (root) disk disk_is_root_disk() { local bname=$(basename "$1") local rootdisk=$(get_root_disk) [ -n "$rootdisk" ] && [ "$bname" = "$rootdisk" ] } # partition_is_boot: returns 0 if a system/firmware partition (hard-protected) # v5.2 ordering: brick-level signals first (size-independent), then the # 4GiB data-space rule, then small-on-system-disk = firmware. # Robust to absent GPT labels and MTK FIT images (root=/dev/fit0): # on a router the small partitions on the internal system disk are # firmware (kernel/rootfs/factory/A-B slots); the large one is user data. partition_is_boot() { # Memoize per device: guard state is invariant during a single RPC (rpcd # runs each call in a fresh process, so the cache never spans requests). # partition_is_data re-enters this, so without caching the whole guard # chain (get_parent_disk / is_external_device / is_live_root_mount / # blkid / part_large_enough / get_root_disk) ran twice per partition. local _bk _bk=$(basename "$1") case "$_BOOT_CACHE" in *"|$_bk=1|"*) return 0 ;; *"|$_bk=0|"*) return 1 ;; esac if _partition_is_boot_impl "$@"; then _BOOT_CACHE="$_BOOT_CACHE|$_bk=1|" return 0 fi _BOOT_CACHE="$_BOOT_CACHE|$_bk=0|" return 1 } _partition_is_boot_impl() { local bname=$(basename "$1") local parent=$(get_parent_disk "$bname") [ -n "$parent" ] || return 1 # (1) external disks never hold system partitions is_external_device "$parent" && return 1 # (2) brick-level, size-independent: the live base system is_live_root_mount "$bname" && return 0 # (2b) squashfs is always read-only firmware local fstype=$(dev_fs_type "$bname") [ "$fstype" = "squashfs" ] && return 0 # (3) a partition above 4GiB is user data space, never firmware # (e.g. 128G eMMC: 2G system + 126G data area; the data area may be # mounted anywhere - /overlay, /mnt/... - still user data, not system) part_large_enough "$bname" && return 1 # (4) small partition on the system disk = firmware (label-independent) local rootdisk=$(get_root_disk) [ -n "$rootdisk" ] && [ "$parent" = "$rootdisk" ] && return 0 return 1 } # partition_is_data: returns 0 if a mounted non-firmware partition on an internal disk partition_is_data() { # Memoize per device (same reasoning as partition_is_boot above). local _dk _dk=$(basename "$1") case "$_DATA_CACHE" in *"|$_dk=1|"*) return 0 ;; *"|$_dk=0|"*) return 1 ;; esac if _partition_is_data_impl "$@"; then _DATA_CACHE="$_DATA_CACHE|$_dk=1|" return 0 fi _DATA_CACHE="$_DATA_CACHE|$_dk=0|" return 1 } _partition_is_data_impl() { local bname=$(basename "$1") local pm=$(get_mount_point "$bname") [ "$pm" = "-" ] && return 1 local parent=$(get_parent_disk "$bname") [ -n "$parent" ] || return 1 is_external_device "$parent" && return 1 partition_is_boot "$1" && return 1 return 0 } # partition_needs_force: returns 0 if a format requires force=1 partition_needs_force() { local bname=$(basename "$1") local parent=$(get_parent_disk "$bname") [ -n "$parent" ] || return 1 is_external_device "$parent" && return 1 # mounted (in use) or existing filesystem - confirm before wiping [ "$(get_mount_point "$bname")" != "-" ] && return 0 local fstype=$(dev_fs_type "$bname") [ -n "$fstype" ] && return 0 # safety net: unmounted, no-fs, small partition on the root disk # (possible A/B inactive firmware slot) - require explicit force local rootdisk=$(get_root_disk) [ -n "$rootdisk" ] && [ "$parent" = "$rootdisk" ] && ! part_large_enough "$bname" && return 0 return 1 } # require_not_boot: hard block, no force override require_not_boot() { local what="$1" logger -t diskbox "BLOCKED(boot): $what" echo '{"code": 2, "error": "Blocked: this is a boot/firmware partition."}' } # require_not_system_disk: hard block, no force override require_not_system_disk() { local what="$1" logger -t diskbox "BLOCKED(system-disk): $what" echo '{"code": 2, "error": "Blocked: this operation targets the system disk (it holds the running system)."}' } # rescan_partitions: refresh the kernel's partition list after a GPT change. # On embedded eMMC (MTK FIT images) parted does not reliably trigger a kernel # rescan: the kernel keeps a stale partition list, so removed partitions' # device nodes survive and new partitions never appear (UI and kernel # disagree). Never rescan while a partition of this disk is mounted - a # rescan can drop kernel partitions, which would corrupt live I/O. rescan_partitions() { local dev="$1" local mounted=$(awk -v d="/dev/$dev" '$1 == d || index($1, d "p") == 1 { print $2; exit }' /proc/mounts 2>/dev/null) if [ -n "$mounted" ]; then logger -t diskbox "WARN(rescan): skipping /dev/$dev - $mounted is mounted" return 1 fi if which partprobe >/dev/null 2>&1; then partprobe "/dev/$dev" >/dev/null 2>&1 logger -t diskbox "rescan: partprobe /dev/$dev rc=$?" elif which blockdev >/dev/null 2>&1; then blockdev --rereadpt "/dev/$dev" >/dev/null 2>&1 logger -t diskbox "rescan: blockdev --rereadpt /dev/$dev rc=$?" fi return 0 } # ===== END SYSTEM-DISK GUARD v5 ===== action_list_devices() { json_init json_add_object "devices" for devpath in /sys/block/sd* /sys/block/mmcblk* /sys/block/nvme* /sys/block/vd* /sys/block/sata*; do [ -e "$devpath" ] || continue local bname=$(basename "$devpath") case "$bname" in *boot*|*rpmb*) continue ;; esac local size_sec=$(cat "$devpath/size" 2>/dev/null || echo 0) local ss=$(cat "$devpath/queue/logical_block_size" 2>/dev/null || echo 512) local size_bytes=$(awk -v s="$size_sec" -v b="$ss" 'BEGIN { printf "%.0f", s*b }') local size_str=$(format_bytes "$size_bytes") local model=$(cat "$devpath/device/model" 2>/dev/null | sed 's/^[ \t]*//;s/[ \t]*$//') local sn=$(cat "$devpath/device/serial" 2>/dev/null | sed 's/^[ \t]*//;s/[ \t]*$//') local temp="N/A" local health="Normal" local p_table="-" local sata_ver="-" local inuse=$(check_inuse "$bname") local dmount=$(get_mount_point "$bname") local dfstype=$(dev_fs_type "$bname") # eMMC enhancement if echo "$bname" | grep -q "^mmcblk"; then local emmc_name=$(cat "$devpath/device/name" 2>/dev/null | sed 's/[[:space:]]//g') local emmc_type=$(cat "$devpath/device/type" 2>/dev/null | sed 's/[[:space:]]//g') if [ -n "$emmc_name" ]; then model="${emmc_type:-eMMC} $emmc_name" else model="eMMC Flash Drive" fi if [ -f "/sys/class/thermal/thermal_zone0/temp" ]; then local tz=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null) if [ -n "$tz" ] && [ "$tz" -gt 0 ] 2>/dev/null; then temp="$(awk -v t="$tz" 'BEGIN {printf "%.1f °C", t/1000}')" fi fi local life=$(cat "$devpath/device/life_time" 2>/dev/null) local eol=$(cat "$devpath/device/pre_eol_info" 2>/dev/null | sed 's/[[:space:]]//g') if [ "$eol" = "0x02" ] || [ "$eol" = "2" ]; then health="Warning (Consuming)" elif [ "$eol" = "0x03" ] || [ "$eol" = "3" ]; then health="Urgent (Replace)" fi if [ -n "$life" ]; then local ta=$(echo "$life" | awk '{print $1}') if [ -n "$ta" ]; then local a_pct=$((ta * 10)) [ "$a_pct" -gt 100 ] && a_pct=100 health="$health (Used A:${a_pct}%)" fi fi else if which smartctl >/dev/null 2>&1; then local s_out=$(run_smartctl "$bname" "-H -A -i -n standby -f brief") local s_temp=$(echo "$s_out" | grep -iE "(Temperature_Celsius|Airflow_Temperature_Cel|Temperature)" | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+$/ && $i < 120 && $i > 0) val=$i} END {if(val) print val}') [ -n "$s_temp" ] && temp="${s_temp} °C" local s_health=$(echo "$s_out" | grep -iE "(overall-health|SMART Health Status)" | head -n1 | awk -F': ' '{print $2}') [ -n "$s_health" ] && health="$s_health" local s_sn=$(echo "$s_out" | grep -i "^Serial Number:" | head -n1 | awk -F': ' '{print $2}' | sed 's/^[ \t]*//;s/[ \t]*$//') [ -n "$s_sn" ] && sn="$s_sn" local s_sata=$(echo "$s_out" | grep -i "^SATA Version is:" | head -n1 | awk -F': ' '{print $2}') [ -n "$s_sata" ] && sata_ver="$s_sata" local s_model=$(echo "$s_out" | grep -iE "^(Device Model|Model Number):" | head -n1 | awk -F': ' '{print $2}' | sed 's/^[ \t]*//;s/[ \t]*$//') [ -n "$s_model" ] && model="$s_model" fi fi # Partition table type if which parted >/dev/null 2>&1; then local pt=$(parted "/dev/$bname" print 2>/dev/null | grep -i "^Partition Table:" | awk -F': ' '{print $2}' | tr 'a-z' 'A-Z') [ "$pt" = "MSDOS" ] && pt="MBR" [ -n "$pt" ] && p_table="$pt" fi json_add_object "$bname" json_add_string "name" "$bname" json_add_string "path" "/dev/$bname" json_add_string "model" "${model:--}" json_add_string "sn" "${sn:--}" json_add_int "size" "$size_bytes" json_add_string "size_formated" "$size_str" json_add_string "temp" "$temp" json_add_string "health_status" "$health" json_add_string "p_table" "$p_table" json_add_string "sata_ver" "$sata_ver" json_add_boolean "inuse" "$inuse" local ds=0 if disk_is_root_disk "$bname" >/dev/null 2>&1; then ds=1; fi json_add_boolean "is_system" "$ds" json_add_string "mount_point" "$dmount" json_add_string "fstype" "${dfstype:-}" # Partitions json_add_array "partitions" for partpath in /sys/block/$bname/$bname*; do [ -e "$partpath" ] || continue local pname=$(basename "$partpath") local psize_sec=$(cat "$partpath/size" 2>/dev/null || echo 0) local psize_bytes=$(awk -v s="$psize_sec" -v b="$ss" 'BEGIN { printf "%.0f", s*b }') local psize_str=$(format_bytes "$psize_bytes") local pmount=$(get_mount_point "$pname") local pinuse=0 [ "$pmount" != "-" ] && pinuse=1 local pfstype=$(dev_fs_type "$pname") json_add_object json_add_string "name" "$pname" json_add_string "path" "/dev/$pname" json_add_int "size" "$psize_bytes" json_add_string "size_formated" "$psize_str" json_add_string "mount_point" "$pmount" json_add_string "fstype" "${pfstype:-}" json_add_boolean "inuse" "$pinuse" local pb=0 pd=0 if partition_is_boot "$pname" >/dev/null 2>&1; then pb=1; fi if partition_is_data "$pname" >/dev/null 2>&1; then pd=1; fi json_add_boolean "is_boot" "$pb" json_add_boolean "is_data" "$pd" json_close_object done json_close_array json_close_object done json_close_object json_dump } action_get_disk_info() { read -r input local dev=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null | sed 's|^/dev/||') if [ -z "$dev" ]; then echo '{"error": "Device not specified"}' return fi if [ ! -e "/sys/block/$dev" ] && [ ! -e "/sys/class/block/$dev" ]; then echo '{"error": "Device not found"}' return fi local devpath="/sys/class/block/$dev" local size_sec=$(cat "$devpath/size" 2>/dev/null || echo 0) local logic_sec=$(cat "$devpath/queue/logical_block_size" 2>/dev/null || echo 512) local phy_sec=$(cat "$devpath/queue/physical_block_size" 2>/dev/null || echo "$logic_sec") local size_bytes=$(awk -v s="$size_sec" -v b="$logic_sec" 'BEGIN { printf "%.0f", s*b }') local size_str=$(format_bytes "$size_bytes") local model=$(cat "$devpath/device/model" 2>/dev/null | sed 's/^[ \t]*//;s/[ \t]*$//') local sn=$(cat "$devpath/device/serial" 2>/dev/null | sed 's/^[ \t]*//;s/[ \t]*$//') local p_table="-" local temp="N/A" local health="Normal" local sata_ver="-" local rota_rate="-" local status="ACTIVE" if which smartctl >/dev/null 2>&1; then local s_out=$(run_smartctl "$dev" "-H -A -i -n standby -f brief") local s_temp=$(echo "$s_out" | grep -iE "(Temperature_Celsius|Airflow_Temperature_Cel|Temperature)" | awk '{for(i=1;i<=NF;i++) if($i ~ /^[0-9]+$/ && $i < 120 && $i > 0) val=$i} END {if(val) print val}') [ -n "$s_temp" ] && temp="${s_temp} °C" local s_health=$(echo "$s_out" | grep -iE "(overall-health|SMART Health Status)" | head -n1 | awk -F': ' '{print $2}') [ -n "$s_health" ] && health="$s_health" local s_sn=$(echo "$s_out" | grep -i "^Serial Number:" | head -n1 | awk -F': ' '{print $2}' | sed 's/^[ \t]*//;s/[ \t]*$//') [ -n "$s_sn" ] && sn="$s_sn" local s_sata=$(echo "$s_out" | grep -i "^SATA Version is:" | head -n1 | awk -F': ' '{print $2}') [ -n "$s_sata" ] && sata_ver="$s_sata" local s_model=$(echo "$s_out" | grep -iE "^(Device Model|Model Number):" | head -n1 | awk -F': ' '{print $2}' | sed 's/^[ \t]*//;s/[ \t]*$//') [ -n "$s_model" ] && model="$s_model" local s_rota=$(echo "$s_out" | grep -i "^Rotation Rate:" | head -n1 | awk -F': ' '{print $2}') [ -n "$s_rota" ] && rota_rate="$s_rota" local s_power=$(echo "$s_out" | grep -i "^Device is in" | head -n1 | awk '{print $4}') [ -n "$s_power" ] && status="$s_power" fi if echo "$dev" | grep -q "^mmcblk"; then local emmc_name=$(cat "$devpath/device/name" 2>/dev/null | sed 's/[[:space:]]//g') local emmc_type=$(cat "$devpath/device/type" 2>/dev/null | sed 's/[[:space:]]//g') model="${emmc_type:-eMMC} ${emmc_name:-Flash Drive}" if [ -f "/sys/class/thermal/thermal_zone0/temp" ]; then local tz=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null) [ -n "$tz" ] && [ "$tz" -gt 0 ] 2>/dev/null && temp="$(awk -v t="$tz" 'BEGIN {printf "%.1f °C", t/1000}')" fi fi json_init json_add_string "name" "$dev" json_add_string "path" "/dev/$dev" json_add_string "model" "${model:--}" json_add_string "sn" "${sn:--}" json_add_int "size" "$size_bytes" json_add_string "size_formated" "$size_str" json_add_int "logic_sec" "$logic_sec" json_add_int "phy_sec" "$phy_sec" json_add_string "sec_size" "${logic_sec}/${phy_sec}" json_add_string "temp" "$temp" json_add_string "health" "$health" json_add_string "health_status" "$health" json_add_string "status" "$status" json_add_string "sata_ver" "$sata_ver" json_add_string "rota_rate" "$rota_rate" json_add_string "type" "$dev" # Parted parse json_add_array "partitions" if which parted >/dev/null 2>&1; then local parted_out=$(parted -s -m "/dev/$dev" unit s print free 2>/dev/null) local pt=$(echo "$parted_out" | grep "^/dev/$dev:" | awk -F: '{print $4}' | tr 'a-z' 'A-Z') [ "$pt" = "MSDOS" ] && pt="MBR" [ -n "$pt" ] && p_table="$pt" # Kernel (sysfs) partition list, as " " lines. # Detects GPT/kernel desync: a region the GPT reports as "free" may # still hold a live kernel partition (removed from the GPT but the # kernel never rescanned - common on eMMC). Such a region must be # presented as a real partition with its true mount/filesystem # state, not as unallocated space the user could create over. local kernel_parts="" for kdev in "/sys/block/$dev/$dev"*; do [ -d "$kdev" ] || continue local kn=$(basename "$kdev") [ "$kn" = "$dev" ] && continue [ -e "$kdev/start" ] || continue local kst=$(cat "$kdev/start" 2>/dev/null) local ksz=$(cat "$kdev/size" 2>/dev/null) case "$kst$ksz" in ''|*[!0-9]*) continue ;; esac [ "$ksz" -gt 0 ] || continue kernel_parts="${kernel_parts}${kn} ${kst} $(awk -v a="$kst" -v b="$ksz" 'BEGIN { printf "%.0f", a+b-1 }') " done local old_ifs="$IFS" IFS=' ' for line in $(echo "$parted_out" | grep "^[0-9-]*:"); do IFS="$old_ifs" # Split the machine-format row (num:start:end:size:fs:flags) with pure # parameter expansion - no forked cut/sed/tr (was ~8 forks/row x N rows). local _r local pnum=${line%%:*} _r=${line#*:} local pstart=${_r%%:*} pstart=${pstart%s} _r=${_r#*:} local pend=${_r%%:*} pend=${pend%s} _r=${_r#*:} local psize=${_r%%:*} psize=${psize%s} _r=${_r#*:} local pfs=${_r%%:*} pfs=${pfs//;/} local part_bytes=$(awk -v s="$psize" -v b="$logic_sec" 'BEGIN { printf "%.0f", s*b }') local part_str=$(format_bytes "$part_bytes") local part_name="-" local part_mount="-" local part_used=0 local part_free=0 local part_usage="-" local part_type="primary" if [ "$pfs" = "free" ]; then # GPT says "free" here, but the kernel may still hold a live # partition covering this region (GPT/kernel desync). If so, # emit it as a real partition with its true state and skip the # "free space" row so the user cannot create over live data. local kmatch=$(printf '%s' "$kernel_parts" | awk -v fs="$pstart" -v fe="$pend" 'NF==3 && $2 >= fs+0 && $3 <= fe+0 { print; exit }') if [ -n "$kmatch" ]; then local kpn=$(echo "$kmatch" | awk '{print $1}') local kps=$(echo "$kmatch" | awk '{print $2}') local kpe=$(echo "$kmatch" | awk '{print $3}') local kno="${kpn#$dev}" kno="${kno#p}" case "$kno" in ''|*[!0-9]*) kno=0 ;; esac local kbytes=$(awk -v e="$kpe" -v s="$kps" -v b="$logic_sec" 'BEGIN { printf "%.0f", (e-s+1)*b }') local kfs=$(dev_fs_type "$kpn") [ -z "$kfs" ] && kfs=$(lsblk -no fstype "/dev/$kpn" 2>/dev/null) [ -z "$kfs" ] && kfs="raw" local kmount=$(get_mount_point "$kpn") local kused=0 kfree=0 kusage="-" if [ "$kmount" != "-" ]; then local kdf=$(df -k "/dev/$kpn" 2>/dev/null | tail -n1) if [ -n "$kdf" ]; then kused=$(echo "$kdf" | awk '{printf "%.0f", $3*1024}') kfree=$(echo "$kdf" | awk '{printf "%.0f", $4*1024}') kusage=$(echo "$kdf" | awk '{print $5}') fi fi local kpb=0 kpd=0 partition_is_boot "$kpn" >/dev/null 2>&1 && kpb=1 partition_is_data "$kpn" >/dev/null 2>&1 && kpd=1 json_add_object json_add_int "number" "$kno" json_add_string "name" "$kpn" json_add_string "path" "/dev/$kpn" json_add_string "sec_start" "$kps" json_add_string "sec_end" "$kpe" json_add_int "size" "$kbytes" json_add_string "size_formated" "$(format_bytes $kbytes)" json_add_string "fs" "$kfs" json_add_string "type" "primary" json_add_string "mount_point" "$kmount" json_add_int "used" "$kused" json_add_string "used_formated" "$(format_bytes $kused)" json_add_int "free" "$kfree" json_add_string "free_formated" "$(format_bytes $kfree)" json_add_string "usage" "$kusage" json_add_boolean "is_boot" "$kpb" json_add_boolean "is_data" "$kpd" json_close_object continue fi pnum="-1" pfs="Free Space" part_type="free" else case "$dev" in sd*|vd*|sata*) part_name="${dev}${pnum}" ;; mmcblk*|nvme*|md*) part_name="${dev}p${pnum}" ;; esac if [ "$pt" = "MBR" ]; then if [ "$pnum" -gt 4 ] 2>/dev/null; then part_type="logical" elif [ "$pnum" -gt 0 ] 2>/dev/null; then local sys_sz=$(cat "/sys/block/$dev/$part_name/size" 2>/dev/null || echo 0) local sys_bytes=$(awk -v s="$sys_sz" -v b="$logic_sec" 'BEGIN { printf "%.0f", s*b }') if [ "$sys_bytes" -gt 0 ] && [ "$sys_bytes" -ne "$part_bytes" ]; then part_type="extended" pfs="-" part_bytes="$sys_bytes" part_str=$(format_bytes "$sys_bytes") fi fi fi if [ "$part_type" != "extended" ]; then if [ -n "$part_name" ] && [ -z "$pfs" ] && which lsblk >/dev/null 2>&1; then pfs=$(lsblk -no fstype "/dev/$part_name" 2>/dev/null) fi [ -z "$pfs" ] && pfs="raw" fi part_mount=$(get_mount_point "$part_name") if [ "$part_mount" != "-" ]; then local df_info=$(df -k "/dev/$part_name" 2>/dev/null | tail -n1) if [ -n "$df_info" ]; then local df_used=$(echo "$df_info" | awk '{print $3}') local df_avail=$(echo "$df_info" | awk '{print $4}') local df_pct=$(echo "$df_info" | awk '{print $5}') part_used=$(awk -v u="$df_used" 'BEGIN { printf "%.0f", u*1024 }') part_free=$(awk -v f="$df_avail" 'BEGIN { printf "%.0f", f*1024 }') part_usage="$df_pct" fi fi fi local pb=0 pd=0 if [ "$pnum" != "-1" ] && [ -n "$part_name" ] && [ "$part_name" != "-" ]; then partition_is_boot "$part_name" >/dev/null 2>&1 && pb=1 partition_is_data "$part_name" >/dev/null 2>&1 && pd=1 fi json_add_object json_add_int "number" "$pnum" json_add_string "name" "$part_name" json_add_string "path" "/dev/$part_name" json_add_string "sec_start" "$pstart" json_add_string "sec_end" "$pend" json_add_int "size" "$part_bytes" json_add_string "size_formated" "$part_str" json_add_string "fs" "$pfs" json_add_string "type" "$part_type" json_add_string "mount_point" "$part_mount" json_add_int "used" "$part_used" json_add_string "used_formated" "$(format_bytes $part_used)" json_add_int "free" "$part_free" json_add_string "free_formated" "$(format_bytes $part_free)" json_add_string "usage" "$part_usage" json_add_boolean "is_boot" "$pb" json_add_boolean "is_data" "$pd" json_close_object done IFS="$old_ifs" fi json_close_array json_add_string "p_table" "$p_table" json_dump } action_get_smart_attr() { read -r input local dev=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null | sed 's|^/dev/||') if [ -z "$dev" ]; then echo '[]' return fi json_init json_add_array "attributes" if echo "$dev" | grep -q "^mmcblk"; then local devpath="/sys/class/block/$dev" [ ! -d "$devpath" ] && devpath="/sys/block/$dev" local m_name=$(cat "$devpath/device/name" 2>/dev/null | sed 's/[[:space:]]//g') local m_type=$(cat "$devpath/device/type" 2>/dev/null | sed 's/[[:space:]]//g') local m_cid=$(cat "$devpath/device/cid" 2>/dev/null | sed 's/[[:space:]]//g') local m_csd=$(cat "$devpath/device/csd" 2>/dev/null | sed 's/[[:space:]]//g') local m_rev=$(cat "$devpath/device/rev" 2>/dev/null | sed 's/[[:space:]]//g') local m_oemid=$(cat "$devpath/device/oemid" 2>/dev/null | sed 's/[[:space:]]//g') local m_manfid=$(cat "$devpath/device/manfid" 2>/dev/null | sed 's/[[:space:]]//g') local m_sn=$(cat "$devpath/device/serial" 2>/dev/null | sed 's/[[:space:]]//g') local m_date=$(cat "$devpath/device/date" 2>/dev/null | sed 's/[[:space:]]//g') local life=$(cat "$devpath/device/life_time" 2>/dev/null) local eol=$(cat "$devpath/device/pre_eol_info" 2>/dev/null | sed 's/[[:space:]]//g') local eol_str="Normal (0x01)" [ "$eol" = "0x02" ] || [ "$eol" = "2" ] && eol_str="Warning (Consuming >80%)" [ "$eol" = "0x03" ] || [ "$eol" = "3" ] && eol_str="Urgent (Replace)" local life_a="Not Available" local life_b="Not Available" if [ -n "$life" ]; then local ta=$(echo "$life" | awk '{print $1}') local tb=$(echo "$life" | awk '{print $2}') if [ -n "$ta" ]; then local a_pct=$((ta * 10)) [ "$a_pct" -gt 100 ] && a_pct=100 life_a="Used ${a_pct}% (SLC Estimated)" fi if [ -n "$tb" ]; then local b_pct=$((tb * 10)) [ "$b_pct" -gt 100 ] && b_pct=100 life_b="Used ${b_pct}% (MLC Estimated)" fi fi json_add_object; json_add_string "key" "Storage Standard"; json_add_string "value" "JEDEC eMMC (EXT_CSD Protocol)"; json_close_object json_add_object; json_add_string "key" "Device Type"; json_add_string "value" "${m_type:-eMMC}"; json_close_object [ -n "$m_name" ] && { json_add_object; json_add_string "key" "Product Name"; json_add_string "value" "$m_name"; json_close_object; } [ -n "$m_sn" ] && { json_add_object; json_add_string "key" "Serial Number"; json_add_string "value" "$m_sn"; json_close_object; } [ -n "$m_date" ] && { json_add_object; json_add_string "key" "Manufacturing Date"; json_add_string "value" "$m_date"; json_close_object; } [ -n "$m_rev" ] && { json_add_object; json_add_string "key" "Hardware Revision"; json_add_string "value" "$m_rev"; json_close_object; } [ -n "$m_manfid" ] && { json_add_object; json_add_string "key" "Manufacturer ID"; json_add_string "value" "$m_manfid"; json_close_object; } [ -n "$m_oemid" ] && { json_add_object; json_add_string "key" "OEM / Application ID"; json_add_string "value" "$m_oemid"; json_close_object; } json_add_object; json_add_string "key" "Pre-EOL Health State"; json_add_string "value" "$eol_str"; json_close_object json_add_object; json_add_string "key" "Device Life Time (Type A - SLC)"; json_add_string "value" "$life_a"; json_close_object json_add_object; json_add_string "key" "Device Life Time (Type B - MLC)"; json_add_string "value" "$life_b"; json_close_object if [ -f "/sys/class/thermal/thermal_zone0/temp" ]; then local tz=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null) [ -n "$tz" ] && [ "$tz" -gt 0 ] 2>/dev/null && { local tz_str="$(awk -v t="$tz" 'BEGIN {printf "%.1f °C", t/1000}')" json_add_object; json_add_string "key" "Current Temperature"; json_add_string "value" "$tz_str"; json_close_object } fi [ -n "$m_cid" ] && { json_add_object; json_add_string "key" "CID Register"; json_add_string "value" "$m_cid"; json_close_object; } [ -n "$m_csd" ] && { json_add_object; json_add_string "key" "CSD Register"; json_add_string "value" "$m_csd"; json_close_object; } else local output=$(run_smartctl "$dev" "-H -A -i") local old_ifs="$IFS" IFS=' ' if echo "$output" | grep -q "NVMe Version:"; then for line in $output; do IFS="$old_ifs" local k=$(echo "$line" | awk -F': ' '{print $1}' | sed 's/^[ \t]*//;s/[ \t]*$//') local v=$(echo "$line" | awk -F': ' '{print $2}' | sed 's/^[ \t]*//;s/[ \t]*$//') if [ -n "$k" ] && [ -n "$v" ]; then json_add_object json_add_string "key" "$k" json_add_string "value" "$v" json_close_object fi done else for line in $output; do IFS="$old_ifs" if echo "$line" | grep -qE '^[[:space:]]*[0-9]+[[:space:]]+[A-Za-z0-9_-]+'; then local id=$(echo "$line" | awk '{printf "%02X", $1}') local attr=$(echo "$line" | awk '{print $2}') local flag=$(echo "$line" | awk '{print $3}') local val=$(echo "$line" | awk '{print $4}') local worst=$(echo "$line" | awk '{print $5}') local thresh=$(echo "$line" | awk '{print $6}') local type=$(echo "$line" | awk '{print $7}') local updated=$(echo "$line" | awk '{print $8}') local raw=$(echo "$line" | awk '{for(i=10;i<=NF;i++) printf "%s ", $i; if(NF>=10) print ""; else print $9}' | sed 's/[ \t]*$//') json_add_object json_add_string "id" "$id" json_add_string "attrbute" "$attr" json_add_string "flag" "$flag" json_add_string "value" "$val" json_add_string "worst" "$worst" json_add_string "thresh" "$thresh" json_add_string "type" "$type" json_add_string "updated" "$updated" json_add_string "raw" "$raw" json_close_object fi done fi IFS="$old_ifs" fi json_close_array json_dump } action_get_format_cmd() { json_init json_add_object "formats" which mkfs.ext2 >/dev/null 2>&1 && json_add_string "ext2" "mkfs.ext2 -F -E lazy_itable_init=1" which mkfs.ext3 >/dev/null 2>&1 && json_add_string "ext3" "mkfs.ext3 -F -E lazy_itable_init=1" which mkfs.ext4 >/dev/null 2>&1 && json_add_string "ext4" "mkfs.ext4 -F -E lazy_itable_init=1" which mkfs.fat >/dev/null 2>&1 && json_add_string "fat32" "mkfs.fat -F 32" which mkfs.vfat >/dev/null 2>&1 && [ ! $(which mkfs.fat) ] && json_add_string "fat32" "mkfs.vfat -F 32" which mkfs.exfat >/dev/null 2>&1 && json_add_string "exfat" "mkfs.exfat" which mkfs.ntfs >/dev/null 2>&1 && json_add_string "ntfs" "mkfs.ntfs -f" which mkswap >/dev/null 2>&1 && json_add_string "swap" "mkswap" which mkfs.btrfs >/dev/null 2>&1 && json_add_string "btrfs" "mkfs.btrfs -f" json_close_object json_dump } action_format_partition() { read -r input local part=$(echo "$input" | jsonfilter -e '@.partition' 2>/dev/null | sed 's|^/dev/||') local fs=$(echo "$input" | jsonfilter -e '@.filesystem' 2>/dev/null) if [ -z "$part" ] || [ ! -e "/dev/$part" ]; then echo '{"code": 1, "error": "Partition not found"}' return fi local cmd="" # [guard v5] boot partition check if partition_is_boot "$part" >/dev/null 2>&1; then require_not_boot "format /dev/$part"; return fi # [guard v5.1] soft: in-use / filesystem-bearing / A/B-slot partition requires force=1 if partition_needs_force "$part" >/dev/null 2>&1; then local force_fmt=$(echo "$input" | jsonfilter -e '@.force' 2>/dev/null) if [ "$force_fmt" != "1" ]; then local msg="Confirm required: this partition holds data. Re-send with force:1 to proceed." if is_overlay_mount "$part" >/dev/null 2>&1; then msg="Confirm required: this is the system data partition (overlay, config + user data). Formatting wipes it (factory reset). Re-send with force:1 to proceed." fi logger -t diskbox "CONFIRM-REQUIRED format /dev/$part (system data, no force flag)" echo "{\"code\": 3, \"error\": \"$msg\"}" return fi fi case "$fs" in ext2) cmd="mkfs.ext2 -F -E lazy_itable_init=1 /dev/$part" ;; ext3) cmd="mkfs.ext3 -F -E lazy_itable_init=1 /dev/$part" ;; ext4) cmd="mkfs.ext4 -F -E lazy_itable_init=1 /dev/$part" ;; fat32) cmd="mkfs.fat -F 32 /dev/$part" ;; exfat) cmd="mkfs.exfat /dev/$part" ;; ntfs) cmd="mkfs.ntfs -f /dev/$part" ;; swap) cmd="mkswap /dev/$part" ;; btrfs) cmd="mkfs.btrfs -f /dev/$part" ;; *) echo '{"code": 1, "error": "Unsupported filesystem"}'; return ;; esac local out=$($cmd 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_mk_partition_table() { read -r input local dev=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null | sed 's|^/dev/||') local tbl=$(echo "$input" | jsonfilter -e '@.table' 2>/dev/null | tr 'A-Z' 'a-z') [ "$tbl" = "mbr" ] && tbl="msdos" if [ "$tbl" != "gpt" ] && [ "$tbl" != "msdos" ]; then echo '{"code": 1, "error": "Invalid table type, must be GPT or MBR"}' return fi # [guard v5] system disk if disk_is_root_disk "$dev" >/dev/null 2>&1; then require_not_system_disk "mktable /dev/$dev"; return fi local out=$(parted -s "/dev/$dev" mktable "$tbl" 2>&1) local res=$? if [ $res -eq 0 ]; then rescan_partitions "$dev" echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_create_partition() { read -r input local dev=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null | sed 's|^/dev/||') local start_sec=$(echo "$input" | jsonfilter -e '@.start_sec' 2>/dev/null | sed 's/s$//') local end_sec=$(echo "$input" | jsonfilter -e '@.end_sec' 2>/dev/null | sed 's/s$//') local ptype=$(echo "$input" | jsonfilter -e '@.type' 2>/dev/null || echo "primary") [ -z "$ptype" ] && ptype="primary" if [ -z "$dev" ] || [ -z "$start_sec" ] || [ -z "$end_sec" ]; then echo '{"code": 1, "error": "Missing parameters"}' return fi # [guard v5.1] creating a partition only consumes unallocated space - # safe on the system disk too (it cannot touch existing partitions), # so no root-disk block here. # Sector alignment: phy_sec / logic_sec, min 2048 local phy_sec=$(cat "/sys/class/block/$dev/queue/physical_block_size" 2>/dev/null || echo 512) local logic_sec=$(cat "/sys/class/block/$dev/queue/logical_block_size" 2>/dev/null || echo 512) local align=$(( phy_sec / logic_sec )) [ "$align" -lt 2048 ] && align=2048 if [ "$start_sec" -lt 2048 ] 2>/dev/null; then start_sec=2048 else local rem=$(( start_sec % align )) if [ "$rem" -ne 0 ]; then start_sec=$(( start_sec + align - rem )) fi fi # Check end_sec format (+size) case "$end_sec" in +*[bkmgtsBKMGTS]) local num=$(echo "$end_sec" | sed 's/^+//;s/[bkmgtsBKMGTS]$//') local unit=$(echo "$end_sec" | grep -o '[bkmgtsBKMGTS]$' | tr 'a-z' 'A-Z') local mult=1 case "$unit" in B) mult=1 ;; S) mult=512 ;; K) mult=1024 ;; M) mult=1048576 ;; G) mult=1073741824 ;; T) mult=1099511627776 ;; esac local added_sectors=$(awk -v n="$num" -v m="$mult" -v b="$logic_sec" 'BEGIN { printf "%.0f", (n*m)/b }') end_sec=$(awk -v s="$start_sec" -v a="$added_sectors" 'BEGIN { printf "%.0f", s+a-1 }') ;; esac # GPT automatic fix local pt_type=$(parted -s "/dev/$dev" print 2>/dev/null | grep -i "^Partition Table:" | awk -F': ' '{print $2}' | tr 'a-z' 'A-Z') if [ "$pt_type" = "GPT" ]; then printf "ok\nfix\n" | parted ---pretend-input-tty "/dev/$dev" print >/dev/null 2>&1 fi local out=$(parted -s -a optimal "/dev/$dev" mkpart "$ptype" "${start_sec}s" "${end_sec}s" 2>&1) local res=$? if [ $res -eq 0 ]; then rescan_partitions "$dev" echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_remove_partition() { read -r input local dev=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null | sed 's|^/dev/||') local num=$(echo "$input" | jsonfilter -e '@.number' 2>/dev/null) if [ -z "$dev" ] || [ -z "$num" ] || [ "$num" = "-1" ]; then echo '{"code": 1, "error": "Invalid partition number"}' return fi # [guard v5.1] on the system disk, only non-system partitions may be removed: # resolve the partition by number and check it individually. if disk_is_root_disk "$dev" >/dev/null 2>&1; then # lsblk's PN column is missing on BusyBox variants # ("lsblk: unknown column: PN"), which would leave the name empty # and trip the conservative system-disk block below. Fall back to # the conventional partition name ($dev p$num) verified against # the device node / sysfs before trusting it. local pname=$(lsblk -no NAME,PN "/dev/$dev" 2>/dev/null | awk -v n="$num" '$2==n { print $1; exit }') if [ -z "$pname" ]; then pname="${dev}p${num}" fi if [ ! -b "/dev/$pname" ] && [ ! -e "/sys/block/$dev/$pname" ]; then require_not_system_disk "remove_partition /dev/$dev #$num (partition not resolved)"; return fi if partition_is_boot "$pname" >/dev/null 2>&1; then require_not_boot "remove /dev/$pname"; return fi fi local out=$(parted -s "/dev/$dev" rm "$num" 2>&1) local res=$? if [ $res -eq 0 ]; then rescan_partitions "$dev" echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_mount() { read -r input local dev=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null) local point=$(echo "$input" | jsonfilter -e '@.point' 2>/dev/null) local fs=$(echo "$input" | jsonfilter -e '@.fs' 2>/dev/null) local opts=$(echo "$input" | jsonfilter -e '@.options' 2>/dev/null) if [ -z "$dev" ] || [ -z "$point" ]; then echo '{"code": 1, "error": "Device and mount point required"}' return fi mkdir -p "$point" local cmd="mount" [ -n "$fs" ] && [ "$fs" != "auto" ] && cmd="$cmd -t $fs" [ -n "$opts" ] && cmd="$cmd -o $opts" cmd="$cmd $dev $point" local out=$($cmd 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_umount() { read -r input local point=$(echo "$input" | jsonfilter -e '@.point' 2>/dev/null) if [ -z "$point" ]; then echo '{"code": 1, "error": "Mount point required"}' return fi # [guard] system mount points: unmounting /, /rom or /overlay* bricks or # crashes the router (live base system / writable rootfs). Hard block. case "$point" in /|/rom|/overlay|/overlay/*) logger -t diskbox "BLOCKED(system-mount): umount $point" echo '{"code": 2, "error": "Blocked: this is a system mount point."}' return ;; esac # [guard] is_boot partitions: unmounting firmware partitions (A/B slots, # kernel, rootfs) can brick the device. Hard block. v5.2 classifies any # partition > 4GiB as data space, so large data partitions (e.g. the # 113G eMMC at /mnt/emmc0) are not affected. local devmp=$(awk -v p="$point" '$2==p { print $1; exit }' /proc/mounts 2>/dev/null) case "$devmp" in /dev/sd*|/dev/mmcblk*|/dev/nvme*|/dev/vd*) partition_is_boot "$devmp" && { logger -t diskbox "BLOCKED(system-partition): umount $point on $devmp" echo '{"code": 2, "error": "Blocked: this is a system partition."}' return } ;; esac local out=$(umount "$point" 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_get_mount_points() { json_init json_add_array "mount_points" local old_ifs="$IFS" IFS=' ' local m_lines=$(awk '$1 ~ /^\/dev\// {print $1 "|" $2 "|" $3 "|" $4 "|" $5 "|" $6}' /proc/mounts 2>/dev/null) for line in $m_lines; do IFS="|" set -- $line local dev="$1" local mp="$2" local fs="$3" local opts="$4" local dump="$5" local pass="$6" IFS="$old_ifs" [ -n "$dev" ] || continue json_add_object json_add_string "device" "$dev" json_add_string "mount_point" "$mp" json_add_string "fs" "$fs" json_add_string "mount_options" "$opts" json_add_string "dump" "$dump" json_add_string "pass" "$pass" json_close_object done IFS="$old_ifs" json_close_array json_dump } action_eject_device() { read -r input local dev=$(echo "$input" | jsonfilter -e '@.device' 2>/dev/null | sed 's|^/dev/||') if [ -z "$dev" ]; then echo '{"code": 1, "error": "Device required"}' return fi if echo "$dev" | grep -q "^md"; then if which mdadm >/dev/null 2>&1; then local members=$(awk -v d="$dev" '$0 ~ d {gsub(/[[\]:]/,""); for(i=5;i<=NF;i++) sub(/\[[0-9]+\]/, "", $i); for(i=5;i<=NF;i++) printf "/dev/%s ", $i}' /proc/mdstat 2>/dev/null) mdadm --stop "/dev/$dev" >/dev/null 2>&1 mdadm --remove "/dev/$dev" >/dev/null 2>&1 for mbr in $members; do mdadm --zero-superblock "$mbr" >/dev/null 2>&1 done if which uci >/dev/null 2>&1 && [ -f /etc/config/mdadm ]; then uci -q delete mdadm uci commit mdadm mdadm -D -s 2>/dev/null | while read -r line; do local dev_item=$(echo "$line" | awk '{print $2}') local uuid_item=$(echo "$line" | grep -o 'UUID=[^ ]*' | cut -d= -f2) if [ -n "$dev_item" ] && [ -n "$uuid_item" ]; then local s=$(uci add mdadm array) uci set mdadm.$s.device="$dev_item" uci set mdadm.$s.uuid="$uuid_item" fi done uci commit mdadm fi echo '{"code": 0, "message": "RAID stopped and removed"}' return fi fi if [ -e "/sys/block/$dev/device/delete" ]; then echo 1 > "/sys/block/$dev/device/delete" echo '{"code": 0, "message": "Device ejected"}' else echo '{"code": 1, "error": "Cannot eject this device"}' fi } action_rescan_disks() { for host in /sys/class/scsi_host/host*/scan; do [ -e "$host" ] && echo "- - -" > "$host" 2>/dev/null done which mdadm >/dev/null 2>&1 && mdadm --assemble --scan >/dev/null 2>&1 echo '{"code": 0, "message": "Scan completed"}' } action_list_raid_devices() { json_init json_add_object "raid_devices" if [ -f /proc/mdstat ]; then local old_ifs="$IFS" IFS=' ' local r_lines=$(awk '/^md[0-9]+ :/ { md=$1; active=$3; level=$4; members=""; for(i=5;i<=NF;i++) { sub(/\[[0-9]+\]/, "", $i); members=(members ? members ", " : "") "/dev/" $i; } print md "|" active "|" level "|" members; }' /proc/mdstat 2>/dev/null) for line in $r_lines; do IFS="|" set -- $line local md="$1" local act="$2" local lvl="$3" local mbrs="$4" IFS="$old_ifs" [ -n "$md" ] || continue local sz=$(cat "/sys/class/block/$md/size" 2>/dev/null || echo 0) local ss=$(cat "/sys/class/block/$md/queue/logical_block_size" 2>/dev/null || echo 512) local size_bytes=$(awk -v s="$sz" -v b="$ss" 'BEGIN { printf "%.0f", s*b }') local st="ACTIVE" if which mdadm >/dev/null 2>&1; then local state=$(mdadm --detail "/dev/$md" 2>/dev/null | grep -i "^[[:space:]]*State :" | awk -F': ' '{print $2}') [ -n "$state" ] && st="$state" fi json_add_object "$md" json_add_string "name" "$md" json_add_string "path" "/dev/$md" json_add_string "level" "$lvl" json_add_string "active" "$(echo $act | tr 'a-z' 'A-Z')" json_add_string "status" "$st" json_add_string "members_str" "$mbrs" json_add_int "size" "$size_bytes" json_add_string "size_formated" "$(format_bytes $size_bytes)" json_close_object done IFS="$old_ifs" fi json_close_object json_dump } action_create_raid() { read -r input local name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) local level=$(echo "$input" | jsonfilter -e '@.level' 2>/dev/null) local members=$(echo "$input" | jsonfilter -e '@.members[@]' 2>/dev/null) if [ -z "$name" ]; then for n in $(seq 0 127); do if [ ! -e "/dev/md$n" ]; then name="/dev/md$n" break fi done fi case "$name" in /dev/*) ;; *) name="/dev/$name" ;; esac local count=$(echo "$members" | wc -w) if [ "$count" -lt 2 ]; then echo '{"code": 1, "error": "At least 2 members required"}' return fi local out=$(mdadm --create "$name" --run --assume-clean --homehost=any --level="$level" --raid-devices="$count" $members 2>&1) local res=$? if [ $res -eq 0 ]; then if which uci >/dev/null 2>&1 && [ -f /etc/config/mdadm ]; then uci -q delete mdadm uci commit mdadm mdadm -D -s 2>/dev/null | while read -r line; do local dev_item=$(echo "$line" | awk '{print $2}') local uuid_item=$(echo "$line" | grep -o 'UUID=[^ ]*' | cut -d= -f2) if [ -n "$dev_item" ] && [ -n "$uuid_item" ]; then local s=$(uci add mdadm array) uci set mdadm.$s.device="$dev_item" uci set mdadm.$s.uuid="$uuid_item" fi done uci commit mdadm /etc/init.d/mdadm enable 2>/dev/null fi echo '{"code": 0, "message": "RAID created successfully"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_list_btrfs_devices() { json_init json_add_object "btrfs_devices" if which btrfs >/dev/null 2>&1; then local old_ifs="$IFS" IFS=' ' local b_lines=$(btrfs filesystem show -d --raw 2>/dev/null | awk ' /^Label:/ { lbl=$2; gsub(/'\''/, "", lbl); uuid=$4; cur_uuid=uuid; labels[cur_uuid]=lbl; } /Total devices/ { used[cur_uuid]=$NF; } /devid/ { sz=$4; path=$NF; sizes[cur_uuid]+=sz; members[cur_uuid]=(members[cur_uuid] ? members[cur_uuid] ", " : "") path; } END { for (u in labels) { print u "|" labels[u] "|" (sizes[u]+0) "|" (used[u]+0) "|" members[u]; } }') for line in $b_lines; do IFS="|" set -- $line local uuid="$1" local lbl="$2" local sz="$3" local used="$4" local mbrs="$5" IFS="$old_ifs" [ -n "$uuid" ] || continue json_add_object "$uuid" json_add_string "uuid" "$uuid" json_add_string "label" "$lbl" json_add_int "size" "$sz" json_add_string "size_formated" "$(format_bytes $sz)" json_add_int "used" "$used" json_add_string "used_formated" "$(format_bytes $used)" json_add_string "members" "$mbrs" json_close_object done IFS="$old_ifs" fi json_close_object json_dump } action_get_btrfs_info() { read -r input local uuid=$(echo "$input" | jsonfilter -e '@.uuid' 2>/dev/null) if [ -z "$uuid" ]; then echo '{"error": "UUID required"}' return fi local mnt="/tmp/.btrfs_tmp" mkdir -p "$mnt" umount "$mnt" >/dev/null 2>&1 mount -t btrfs -o subvol=/ "UUID=$uuid" "$mnt" >/dev/null 2>&1 json_init if [ -d "$mnt" ]; then local show=$(btrfs filesystem show --raw "$mnt" 2>/dev/null) local usage_out=$(btrfs filesystem usage -b "$mnt" 2>/dev/null) local label=$(echo "$show" | grep "^Label:" | head -n1 | awk '{print $2}' | sed "s/'//g") local used=$(echo "$usage_out" | grep -i "^[[:space:]]*Used:" | head -n1 | awk '{print $2}') local free=$(echo "$usage_out" | grep -i "^[[:space:]]*Free (estimated):" | head -n1 | awk '{print $3}') local d_raid=$(echo "$usage_out" | grep -i "^Data," | head -n1 | awk '{print $2}') local m_raid=$(echo "$usage_out" | grep -i "^Metadata," | head -n1 | awk '{print $2}') local mbrs=$(echo "$show" | grep "path" | awk '{print $NF}' | tr '\n' ',' | sed 's/,$//;s/,/, /g') local total=$(awk -v u="${used:-0}" -v f="${free:-0}" 'BEGIN { printf "%.0f", u+f }') local pct="0%" [ "$total" -gt 0 ] && pct="$(awk -v u="$used" -v t="$total" 'BEGIN {printf "%.2f%%", (u/t)*100}')" json_add_string "uuid" "$uuid" json_add_string "label" "${label:--}" json_add_string "data_raid_level" "${d_raid:--}" json_add_string "metadata_raid_level" "${m_raid:--}" json_add_int "size" "$total" json_add_string "size_formated" "$(format_bytes $total)" json_add_int "used" "${used:-0}" json_add_string "used_formated" "$(format_bytes ${used:-0})" json_add_int "free" "${free:-0}" json_add_string "free_formated" "$(format_bytes ${free:-0})" json_add_string "usage" "$pct" json_add_string "members" "$mbrs" fi json_dump } action_get_btrfs_subvolumes() { read -r input local uuid=$(echo "$input" | jsonfilter -e '@.uuid' 2>/dev/null) local mnt="/tmp/.btrfs_tmp" mkdir -p "$mnt" umount "$mnt" >/dev/null 2>&1 mount -t btrfs -o subvol=/ "UUID=$uuid" "$mnt" >/dev/null 2>&1 json_init json_add_array "subvolumes" if which btrfs >/dev/null 2>&1 && [ -d "$mnt" ]; then local def_id=$(btrfs subvolume get-default "$mnt" 2>/dev/null | awk '{print $2}') local root_id="5" json_add_object json_add_string "id" "$root_id" json_add_string "path" "/" json_add_string "top_level" "0" json_add_boolean "default_subvolume" $( [ "$def_id" = "$root_id" ] && echo 1 || echo 0 ) json_close_object local old_ifs="$IFS" IFS=' ' local sv_lines=$(btrfs subvolume list -gcu "$mnt" 2>/dev/null) for line in $sv_lines; do IFS="$old_ifs" local id=$(echo "$line" | awk '{print $2}') local top=$(echo "$line" | awk '{print $8}') local sv_uuid=$(echo "$line" | awk '{print $10}') local p=$(echo "$line" | awk '{print $12}') json_add_object json_add_string "id" "$id" json_add_string "path" "/$p" json_add_string "top_level" "$top" json_add_string "uuid" "$sv_uuid" json_add_boolean "default_subvolume" $( [ "$def_id" = "$id" ] && echo 1 || echo 0 ) json_close_object done IFS="$old_ifs" fi json_close_array json_dump } action_create_btrfs() { read -r input local label=$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null) local level=$(echo "$input" | jsonfilter -e '@.level' 2>/dev/null || echo "single") local members=$(echo "$input" | jsonfilter -e '@.members[@]' 2>/dev/null) local count=$(echo "$members" | wc -w) if [ "$count" -lt 1 ]; then echo '{"code": 1, "error": "Members required"}' return fi local opt="" [ -n "$label" ] && opt="-L $label" local out=$(mkfs.btrfs -f $opt -d "$level" $members 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_btrfs_subvol_create() { read -r input local uuid=$(echo "$input" | jsonfilter -e '@.uuid' 2>/dev/null) local path=$(echo "$input" | jsonfilter -e '@.path' 2>/dev/null) local mnt="/tmp/.btrfs_tmp" mount -t btrfs -o subvol=/ "UUID=$uuid" "$mnt" >/dev/null 2>&1 local out=$(btrfs subvolume create "$mnt$path" 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_btrfs_subvol_delete() { read -r input local uuid=$(echo "$input" | jsonfilter -e '@.uuid' 2>/dev/null) local path=$(echo "$input" | jsonfilter -e '@.path' 2>/dev/null) local mnt="/tmp/.btrfs_tmp" mount -t btrfs -o subvol=/ "UUID=$uuid" "$mnt" >/dev/null 2>&1 local out=$(btrfs subvolume delete "$mnt$path" 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_btrfs_subvol_set_default() { read -r input local uuid=$(echo "$input" | jsonfilter -e '@.uuid' 2>/dev/null) local path=$(echo "$input" | jsonfilter -e '@.path' 2>/dev/null) local mnt="/tmp/.btrfs_tmp" mount -t btrfs -o subvol=/ "UUID=$uuid" "$mnt" >/dev/null 2>&1 local out=$(btrfs subvolume set-default "$mnt$path" 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_btrfs_set_label() { read -r input local uuid=$(echo "$input" | jsonfilter -e '@.uuid' 2>/dev/null) local label=$(echo "$input" | jsonfilter -e '@.label' 2>/dev/null) local mnt="/tmp/.btrfs_tmp" mount -t btrfs -o subvol=/ "UUID=$uuid" "$mnt" >/dev/null 2>&1 local out=$(btrfs filesystem label "$mnt" "$label" 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } action_btrfs_snapshot_create() { read -r input local uuid=$(echo "$input" | jsonfilter -e '@.uuid' 2>/dev/null) local src=$(echo "$input" | jsonfilter -e '@.source' 2>/dev/null) local dst=$(echo "$input" | jsonfilter -e '@.dest' 2>/dev/null) local ro=$(echo "$input" | jsonfilter -e '@.readonly' 2>/dev/null) local mnt="/tmp/.btrfs_tmp" mount -t btrfs -o subvol=/ "UUID=$uuid" "$mnt" >/dev/null 2>&1 [ -z "$dst" ] && dst="/.snapshot${src}/$(date +%Y%m%d%H%M%S)" mkdir -p "$(dirname "$mnt$dst")" local opt="" [ "$ro" = "1" ] || [ "$ro" = "true" ] && opt="-r" local out=$(btrfs subvolume snapshot $opt "$mnt$src" "$mnt$dst" 2>&1) local res=$? if [ $res -eq 0 ]; then echo '{"code": 0, "message": "OK"}' else json_init json_add_int "code" "$res" json_add_string "error" "$out" json_dump fi } # Main entry point case "$1" in list) echo '{ "list_devices": {}, "get_disk_info": {"device": "string"}, "get_smart_attr": {"device": "string"}, "get_format_cmd": {}, "format_partition": {"partition": "string", "filesystem": "string", "force": "int"}, "mk_partition_table": {"device": "string", "table": "string"}, "create_partition": {"device": "string", "start_sec": "string", "end_sec": "string", "type": "string"}, "remove_partition": {"device": "string", "number": "int"}, "mount": {"device": "string", "point": "string", "fs": "string", "options": "string"}, "umount": {"point": "string"}, "get_mount_points": {}, "eject_device": {"device": "string"}, "rescan_disks": {}, "list_raid_devices": {}, "create_raid": {"name": "string", "level": "string", "members": "array"}, "list_btrfs_devices": {}, "get_btrfs_info": {"uuid": "string"}, "get_btrfs_subvolumes": {"uuid": "string"}, "create_btrfs": {"label": "string", "level": "string", "members": "array"}, "btrfs_subvol_create": {"uuid": "string", "path": "string"}, "btrfs_subvol_delete": {"uuid": "string", "path": "string"}, "btrfs_subvol_set_default": {"uuid": "string", "path": "string"}, "btrfs_set_label": {"uuid": "string", "label": "string"}, "btrfs_snapshot_create": {"uuid": "string", "source": "string", "dest": "string", "readonly": "boolean"} }' ;; call) case "$2" in list_devices) action_list_devices ;; get_disk_info) action_get_disk_info ;; get_smart_attr) action_get_smart_attr ;; get_format_cmd) action_get_format_cmd ;; format_partition) action_format_partition ;; mk_partition_table) action_mk_partition_table ;; create_partition) action_create_partition ;; remove_partition) action_remove_partition ;; mount) action_mount ;; umount) action_umount ;; get_mount_points) action_get_mount_points ;; eject_device) action_eject_device ;; rescan_disks) action_rescan_disks ;; list_raid_devices) action_list_raid_devices ;; create_raid) action_create_raid ;; list_btrfs_devices) action_list_btrfs_devices ;; get_btrfs_info) action_get_btrfs_info ;; get_btrfs_subvolumes) action_get_btrfs_subvolumes ;; create_btrfs) action_create_btrfs ;; btrfs_subvol_create) action_btrfs_subvol_create ;; btrfs_subvol_delete) action_btrfs_subvol_delete ;; btrfs_subvol_set_default) action_btrfs_subvol_set_default ;; btrfs_set_label) action_btrfs_set_label ;; btrfs_snapshot_create) action_btrfs_snapshot_create ;; *) echo '{"error": "Method not found"}' ;; esac ;; esac