diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34da9a7fd9..29c28614bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -199,6 +199,16 @@ Below are minimal, copy-paste-ready examples that follow DietPi conventions. log=1 G_WHIP_VIEWFILE "$FP_LOG" || return ``` +- `G_TRUNCATE_MID` (shorten long strings by squishing the middle characters): + ``` + G_TRUNCATE_MID "Long text to be shortened by removing the middle" 26 + # -> "Long text to... the middle" + G_TRUNCATE_MID "Long text" 5 + # -> "L...t" + G_TRUNCATE_MID "Text" 3 + # -> "Tex" + ``` + - `Save()` persistence pattern (follow dietpi-banner conventions): ``` Save(){ diff --git a/dietpi/func/dietpi-banner b/dietpi/func/dietpi-banner index 2da3f34cfb..a02041674d 100755 --- a/dietpi/func/dietpi-banner +++ b/dietpi/func/dietpi-banner @@ -5,6 +5,7 @@ # #//////////////////////////////////// # Created by Daniel Knight / daniel.knight@dietpi.com / dietpi.com + # Updated May 2026 by Tim Olson / timjolson@users.noreply.github.com # #//////////////////////////////////// # @@ -22,6 +23,8 @@ INPUT=$* # Import DietPi-Globals -------------------------------------------------------------- + # disable shellcheck for non-constant `source` (`.`) + # shellcheck disable=SC1090 . /boot/dietpi/func/dietpi-globals # - Allow concurrent banner prints but a single menu call only if [[ ! $INPUT ]] @@ -39,63 +42,168 @@ # Globals #///////////////////////////////////////////////////////////////////////////////////// readonly FP_SAVEFILE='/boot/dietpi/.dietpi-banner' - readonly FP_CUSTOM='/boot/dietpi/.dietpi-banner_custom' readonly FP_BANNERWRAP_AWK='/boot/dietpi/func/dietpi-banner-wrap.awk' - aDESCRIPTION=( - - 'Device model' - 'Uptime' - 'CPU temp' - 'FQDN/hostname' - 'NIS domainname' - 'LAN IP' - 'WAN IP' - 'Disk usage (RootFS)' - 'Disk usage (userdata)' - 'Weather (wttr.in)' - 'Custom banner entry' - 'Display DietPi useful commands?' - 'MOTD' - 'VPN status' - 'Large hostname' - 'Print credits' - 'Let'\''s Encrypt cert status' - 'RAM usage' - 'Load average' - 'Word-wrap lines on small screens' - 'Kernel' - ) + # ---------------------------------------------------------------------------- + # Custom preferences initialisation helper. It loads the user-saved settings file + # ($FP_SAVEFILE) so custom colours, commands, and other preferences are applied before they are used. + # Color and format references: + # `https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit` + # `https://invisible-island.net/xterm/ctlseqs/ctlseqs.html` under section "Character Attributes (SGR)" + # `https://www.ditig.com/256-colors-cheat-sheet` + # `https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters` + # + # Usage: `DIETPI_BANNER_LOAD_PREFS` + # ---------------------------------------------------------------------------- + DIETPI_BANNER_LOAD_PREFS() + { + declare -gA aCOLOUR=( + # Default colors + [ACCENT]='\e[38;5;154m' # DietPi green | Lines, bullets and separators + [STRONG]='\e[1m' # Bold white | Main descriptions + [WEAK]='\e[90m' # Grey | Subdued text + [ALERT]='\e[91m' # Red | Update notifications / Bad state + [GOOD]='\e[1;32m' # Green | Good state + [HIGHLIGHT]='\e[1;33m' # Yellow | Warning state + [ALT]='\e[1;36m' # Blue | Dynamic match + ) + declare -g COLOUR_RESET='\e[0m' # Reset formatting to default (do not override this) - # Set defaults: Disable CPU temp by default in VMs - if (( $G_HW_MODEL == 20 )) - then - aENABLED=(1 0 0 0 0 1 0 1 0 0 0 1 1 0 0 1 0 0 0 0 0) - else - aENABLED=(1 0 1 0 0 1 0 0 0 0 0 1 1 0 0 1 0 0 0 0 0) - fi + # Load settings here, to have chosen custom CLI colors applied + # disable shellcheck for non-constant `source` (`.`) + # shellcheck disable=SC1090 + [[ -f $FP_SAVEFILE ]] && . "$FP_SAVEFILE" + + # Migrate legacy numeric-indexed aENABLED (from older FP_SAVEFILE) to associative form + # Only perform migration when numeric indices exist in the loaded aENABLED + if declare -p aENABLED >/dev/null 2>&1; then + local has_numeric=0 + for k in "${!aENABLED[@]}"; do + if [[ "$k" =~ ^[0-9]+$ ]]; then has_numeric=1; break; fi + done + if (( has_numeric )); then + # Prefer LEGACY_INDEXED_BANNER_KEYS for a stable mapping; fallback to MENU_ITEMS + local -n key_map_ref=LEGACY_INDEXED_BANNER_KEYS + if [[ ${#LEGACY_INDEXED_BANNER_KEYS[@]} -eq 0 && ${#MENU_ITEMS[@]} -gt 0 ]]; then + key_map_ref=MENU_ITEMS + fi + declare -gA _tmp_enabled=() + # First, map numeric indices explicitly so legacy numeric prefs take precedence + for (( i=0; i<${#key_map_ref[@]}; i++ )); do + if [[ -n "${aENABLED[$i]+x}" ]]; then + key=${key_map_ref[$i]} + [[ -n "$key" ]] && _tmp_enabled[$key]=${aENABLED[$i]:-0} + fi + done + # Then copy any remaining non-numeric keys that weren't set by numeric mapping + for idx in "${!aENABLED[@]}"; do + if [[ ! "$idx" =~ ^[0-9]+$ ]]; then + key=$idx + # don't overwrite keys already set by numeric mapping + [[ -n "${_tmp_enabled[$key]+x}" ]] && continue + [[ -n "$key" ]] && _tmp_enabled[$key]=${aENABLED[$idx]:-0} + fi + done + # replace aENABLED with associative mapping + unset aENABLED + declare -gA aENABLED + for k in "${!_tmp_enabled[@]}"; do aENABLED[$k]=${_tmp_enabled[$k]}; done + + # Mark that a migration from legacy numeric indices occurred during this run + declare -g DIETPI_BANNER_MIGRATED_DURING_RUN=1 + unset _tmp_enabled + fi + fi + } - COLOUR_RESET='\e[0m' - aCOLOUR=( + # Use associative arrays keyed by short names derived from the description + declare -gA aDESCRIPTION=( + [device_model]='Device model' + [uptime]='Uptime' + [cpu_temp]='CPU temp' + [hostname]='FQDN/hostname' + [nis_domainname]='NIS domainname' + [lan_ip]='LAN IP' + [wan_ip]='WAN IP' + [disk_rootfs]='Disk usage (RootFS)' + [disk_userdata]='Disk usage (userdata)' + [weather]='Weather (wttr.in)' + [custom_commands]='Custom commands' + [useful_commands]='Display DietPi useful commands?' + [motd]='MOTD' + [vpn_status]='VPN status' + [large_hostname]='Large hostname' + [print_credits]='Print credits' + [letsencrypt]='Let'\''s Encrypt cert status' + [ram_usage]='RAM usage' + [load_average]='Load average' + [word_wrap]='Word-wrap lines on small screens' + [kernel]='Kernel' + [network_usage]='Network Usage' + [disk_usage]='Disk Usage' + [systemd_status]='Systemd Status' + [fail2ban_status]='Fail2Ban Status' + ) + # Set the array order for the menu, items must be listed here to show up + declare -ga MENU_ITEMS=( + large_hostname device_model uptime kernel cpu_temp ram_usage load_average hostname nis_domainname \ + lan_ip wan_ip vpn_status disk_rootfs disk_userdata weather letsencrypt systemd_status fail2ban_status \ + network_usage disk_usage custom_commands useful_commands motd print_credits word_wrap kernel + ) - '\e[38;5;154m' # DietPi green | Lines, bullets and separators - '\e[1m' # Bold white | Main descriptions - '\e[90m' # Grey | Credits - '\e[91m' # Red | Update notifications + # Keep a fixed index->key mapping to support numeric-index migration even if MENU_ITEMS changes later + # LEGACY_INDEXED_BANNER_KEYS maps legacy numeric indices to the current key names (0 -> device_model, 1 -> uptime, ...) + ############## + ## DO NOT CHANGE. THIS IS REQUIRED TO MAINTAIN LEGACY INDEX->KEY MAPPING FOR MIGRATION + ############## + declare -ga LEGACY_INDEXED_BANNER_KEYS=( + device_model uptime cpu_temp hostname nis_domainname lan_ip wan_ip disk_rootfs disk_userdata weather custom_commands useful_commands motd vpn_status large_hostname print_credits letsencrypt ram_usage load_average word_wrap kernel network_usage disk_usage systemd_status fail2ban_status ) + ############## + ## DO NOT CHANGE. THIS IS REQUIRED TO MAINTAIN LEGACY INDEX->KEY MAPPING FOR MIGRATION + ############## + ## Set default options + # Initialize aENABLED from aDESCRIPTION keys + declare -gA aENABLED + for key in "${!aDESCRIPTION[@]}"; do + aENABLED[$key]=0 + done + # Set defaults: + aENABLED[device_model]=1 + aENABLED[lan_ip]=1 + aENABLED[disk_rootfs]=1 + aENABLED[useful_commands]=1 + aENABLED[motd]=1 + aENABLED[print_credits]=1 + # Enable CPU temp by default except in VMs + (( $G_HW_MODEL != 20 )) && aENABLED[cpu_temp]=1 # Folding type (colon, dash, fixed), and fixed indent BW_INDENT_TYPE='colon' BW_INDENT_FIXED=3 - # Load settings here, to have chosen ${aCOLOUR[0]} applied to below strings - # shellcheck disable=SC1090 - [[ -f $FP_SAVEFILE ]] && . "$FP_SAVEFILE" + ## Set default custom patterns and commands + # shellcheck disable=SC2016 # Disable warning about single quotes in echo -e + aCUSTOM_COMMANDS[0]='echo -e "$CLI_BULLET ${aCOLOUR[ALERT]}Hello ${aCOLOUR[GOOD]}World! ${COLOUR_RESET} :)"' + # Default disk patterns + aDISK_SPACE_PATTERNS=("/" "/mnt/dietpi_userdata") + + ## Load user preferences + # Flag indicating whether a legacy numeric->associative migration happened during this run + declare -g DIETPI_BANNER_MIGRATED_DURING_RUN=0 + # Override the above defaults as needed + DIETPI_BANNER_LOAD_PREFS - GREEN_LINE=" ${aCOLOUR[0]}─────────────────────────────────────────────────────$COLOUR_RESET" - GREEN_BULLET=" ${aCOLOUR[0]}-$COLOUR_RESET" - GREEN_SEPARATOR="${aCOLOUR[0]}:$COLOUR_RESET" + # Derived convenience strings + CLI_LINE=" ${aCOLOUR[ACCENT]}─────────────────────────────────────────────────────$COLOUR_RESET" + CLI_BULLET=" ${aCOLOUR[ACCENT]}-$COLOUR_RESET" + CLI_SEPARATOR="${aCOLOUR[ACCENT]}:$COLOUR_RESET" + # IP address detection (IPv4) + IP4_re='([0-9]{1,3}\.){3}[0-9]{1,3}' + IP4_re_cidr='([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}' + + # DietPi version string DIETPI_VERSION="$G_DIETPI_VERSION_CORE.$G_DIETPI_VERSION_SUB.$G_DIETPI_VERSION_RC" if [[ $G_GITOWNER != 'MichaIng' ]] then @@ -154,21 +262,49 @@ Save() { - # Custom entry description - echo "aDESCRIPTION[10]='${aDESCRIPTION[10]}'" - - for i in "${!aDESCRIPTION[@]}" - do - echo "aENABLED[$i]=${aENABLED[$i]}" + # Persist associative aENABLED as simple assignments (one per line) + for key in "${MENU_ITEMS[@]}"; do + val=${aENABLED[$key]:-0} + printf "aENABLED[%s]=%s\n" "$key" "$val" done + # Persist only the primary colour slots. Convert any actual + # ESC bytes to the two-character sequence \e so the saved file is + # portable when sourced in different shells. for i in "${!aCOLOUR[@]}" do - echo "aCOLOUR[$i]='${aCOLOUR[$i]}'" + # Don't save the RESET colour slot + [[ "$i" == "RESET" ]] && continue + + val="${aCOLOUR[$i]}" + # Replace actual ESC bytes (0x1b) with literal \e + esc=$(printf '%s' "$val" | sed $'s/\x1b/\\e/g') + # Escape single quotes for safe embedding + esc=${esc//\'/\\\'} + echo "aCOLOUR[$i]='$esc'" done + # Persist aDISK_SPACE_PATTERNS (indices 0..N) + for i in "${!aDISK_SPACE_PATTERNS[@]}"; do + patt=${aDISK_SPACE_PATTERNS[$i]} + patt=${patt//\'/\\\'} + printf "aDISK_SPACE_PATTERNS[%s]='%s'\n" "$i" "$patt" + done + + # Persist aCUSTOM_COMMANDS (indices 0..N) + for i in "${!aCUSTOM_COMMANDS[@]}"; do + cmd=${aCUSTOM_COMMANDS[$i]} + cmd=${cmd//\'/\\\'} + printf "aCUSTOM_COMMANDS[%s]='%s'\n" "$i" "$cmd" + done + + # Persist BW indent settings echo "BW_INDENT_TYPE='$BW_INDENT_TYPE'" echo "BW_INDENT_FIXED=$BW_INDENT_FIXED" + + # Clear migration flag now that prefs are persisted + DIETPI_BANNER_MIGRATED_DURING_RUN=0 + return 0 } Print_Header() @@ -176,57 +312,97 @@ # DietPi update available? if Check_DietPi_Update then - local text_update_available_date="${aCOLOUR[3]}Update available" + local text_update_available_date="${aCOLOUR[HIGHLIGHT]}Update available" # New DietPi live patches available? elif Check_DietPi_Live_Patches then - local text_update_available_date="${aCOLOUR[3]}New live patches available" + local text_update_available_date="${aCOLOUR[HIGHLIGHT]}New live patches available" # APT update available? elif Check_APT_Updates then - local text_update_available_date="${aCOLOUR[3]}$PACKAGE_COUNT APT updates available" + local text_update_available_date="${aCOLOUR[HIGHLIGHT]}$PACKAGE_COUNT APT updates available" # Reboot required to finalise kernel upgrade? elif Check_Reboot then - local text_update_available_date="${aCOLOUR[3]}Reboot required" + local text_update_available_date="${aCOLOUR[ALERT]}Reboot required" else local locale=$(sed -n '/^[[:blank:]]*AUTO_SETUP_LOCALE=/{s/^[^=]*=//p;q}' /boot/dietpi.txt) local text_update_available_date=$(LC_ALL=${locale:-C.UTF-8} date '+%R - %a %x') fi - echo -e "$GREEN_LINE - ${aCOLOUR[1]}DietPi v$DIETPI_VERSION$COLOUR_RESET $GREEN_SEPARATOR $text_update_available_date$COLOUR_RESET -$GREEN_LINE" + echo -e "$CLI_LINE + ${aCOLOUR[STRONG]}DietPi v$DIETPI_VERSION$COLOUR_RESET $CLI_SEPARATOR $text_update_available_date$COLOUR_RESET +$CLI_LINE" } - Print_Local_Ip() + Print_Subheader() { - (( ${aENABLED[5]} )) || return 0 + # ON THE CURRENT LINE Output green line separator with subheader title "$1" + echo -e "\r$CLI_LINE + ${aCOLOUR[STRONG]}$1 $CLI_SEPARATOR" + } + + Get_Local_Ip() + { + (( ${aENABLED[lan_ip]} )) || return 0 local iface=$(G_GET_NET -q iface) local ip=$(G_GET_NET -q ip) - echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[5]} $GREEN_SEPARATOR ${ip:-Use dietpi-config to setup a connection} (${iface:-NONE})" + echo "${ip:-Use dietpi-config to setup a connection} (${iface:-NONE})" } - Print_Cert_Status() + Get_Cert_Status() { - # Let's Encrypt cert status - MUST be run as root - local i - for i in /etc/letsencrypt/live/*/cert.pem - do - # shellcheck disable=SC2292 - [ -f "$i" ] || continue - openssl x509 -enddate -noout -in "$i" | mawk '/notAfter=/{print "Valid until "$4"-"substr($1,10)"-"$2" "$3}' - return 0 - done - echo 'No certificate found' + Get_Cert_Status_Raw() + { + # helper function so that we can run it with sudo in non-root mode without needing to export all the above functions and variables. + # also is `dash` compatible + local i + for i in /etc/letsencrypt/live/*/cert.pem + do + # disable shellcheck for 'require-double-brackets' so it is `dash` compatible + # shellcheck disable=SC2292 + [ -f "$i" ] || continue + openssl x509 -enddate -noout -in "$i" | mawk '/notAfter=/{print "Valid until "$4"-"substr($1,10)"-"$2" "$3}' + return 0 + done + return 1 + } + + stat="" + code=0 + if [[ "$(id -u)" = "0" ]] + then + # Running as root + stat=$(Get_Cert_Status_Raw 2>/dev/null) + code=$? + else + # Running as non-root: Fail silently without NOPASSWD to avoid password prompt + stat=$(sudo -n dash -c "$(declare -f Get_Cert_Status_Raw); Get_Cert_Status_Raw 2>&1" 2> /dev/null) + code=$? + fi + + case $code in + 0) + case "$stat" in + Valid*) stat="${aCOLOUR[WEAK]}$stat${COLOUR_RESET}" ;; + No\ certificate*) stat="${aCOLOUR[ALERT]}$stat${COLOUR_RESET}" ;; + *) stat="${aCOLOUR[HIGHLIGHT]}$stat${COLOUR_RESET}" ;; + esac + ;; # stat is valid + 1) stat="${aCOLOUR[ALERT]}No certificate found${COLOUR_RESET}" ;; + 2) stat="${aCOLOUR[HIGHLIGHT]}NOPASSWD sudo required${COLOUR_RESET}" ;; + *) stat="${aCOLOUR[HIGHLIGHT]}Unable to obtain cert status${COLOUR_RESET}" ;; + esac + + echo "$stat" } Print_Credits() { - echo -e " ${aCOLOUR[2]}DietPi Team : https://github.com/MichaIng/DietPi#the-dietpi-project-team" + echo -e " ${aCOLOUR[WEAK]}DietPi Team : https://github.com/MichaIng/DietPi#the-dietpi-project-team" [[ -f '/boot/dietpi/.prep_info' ]] && mawk 'NR==1 {sub(/^0$/,"DietPi Core Team");a=$0} NR==2 {print " Image by : "a" (pre-image: "$0")"}' /boot/dietpi/.prep_info @@ -241,32 +417,382 @@ $GREEN_LINE" # DietPi update available? if [[ $AVAILABLE_UPDATE ]] then - echo -e " ${aCOLOUR[1]}dietpi-update$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to update DietPi from v$DIETPI_VERSION to v$AVAILABLE_UPDATE$COLOUR_RESET\n" + echo -e " ${aCOLOUR[STRONG]}dietpi-update$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[HIGHLIGHT]}Run now to update DietPi from v$DIETPI_VERSION to v$AVAILABLE_UPDATE$COLOUR_RESET\n" # New DietPi live patches available? elif (( $LIVE_PATCHES )) then - echo -e " ${aCOLOUR[1]}dietpi-update$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to check out new available DietPi live patches$COLOUR_RESET\n" + echo -e " ${aCOLOUR[STRONG]}dietpi-update$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[HIGHLIGHT]}Run now to check out new available DietPi live patches$COLOUR_RESET\n" # APT updates available? elif (( $PACKAGE_COUNT )) then - echo -e " ${aCOLOUR[1]}apt upgrade$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to apply $PACKAGE_COUNT available APT package upgrades$COLOUR_RESET\n" + echo -e " ${aCOLOUR[STRONG]}apt upgrade$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[HIGHLIGHT]}Run now to apply $PACKAGE_COUNT available APT package upgrades$COLOUR_RESET\n" # Reboot required to finalise kernel upgrade? elif (( $REBOOT_REQUIRED )) then - echo -e " ${aCOLOUR[1]}reboot$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}A reboot is required to finalise a recent kernel upgrade$COLOUR_RESET\n" + echo -e " ${aCOLOUR[STRONG]}reboot$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[ALERT]}A reboot is required to finalise a recent kernel upgrade$COLOUR_RESET\n" fi } Print_Useful_Commands() { - echo -e " ${aCOLOUR[1]}dietpi-launcher$COLOUR_RESET $GREEN_SEPARATOR All the DietPi programs in one place - ${aCOLOUR[1]}dietpi-config$COLOUR_RESET $GREEN_SEPARATOR Feature rich configuration tool for your device - ${aCOLOUR[1]}dietpi-software$COLOUR_RESET $GREEN_SEPARATOR Select optimised software for installation - ${aCOLOUR[1]}htop$COLOUR_RESET $GREEN_SEPARATOR Resource monitor - ${aCOLOUR[1]}cpu$COLOUR_RESET $GREEN_SEPARATOR Shows CPU information and stats\n" + echo -e " ${aCOLOUR[STRONG]}dietpi-launcher$COLOUR_RESET $CLI_SEPARATOR All the DietPi programs in one place + ${aCOLOUR[STRONG]}dietpi-config$COLOUR_RESET $CLI_SEPARATOR Feature rich configuration tool for your device + ${aCOLOUR[STRONG]}dietpi-software$COLOUR_RESET $CLI_SEPARATOR Select optimised software for installation + ${aCOLOUR[STRONG]}htop$COLOUR_RESET $CLI_SEPARATOR Resource monitor + ${aCOLOUR[STRONG]}cpu$COLOUR_RESET $CLI_SEPARATOR Shows CPU information and stats\n" + } + + Print_Item_State() + { + # Print a single line item with green bullet, description "$1", green separator and state "$2" + # state should contain color and format codes as needed + local subtitle="$1" + local state="$2" + local width="${3:-}" + local color="${aCOLOUR[STRONG]}" + printf "%b%b %-${width}b %b %b\n" \ + "$CLI_BULLET" "${color}" "$subtitle" "$CLI_SEPARATOR" "$state" + } + + Get_Systemd_Status() + { + # Print systemd overall status based on "systemctl --failed" + FAILED_CNT=$(systemctl --failed --no-legend --no-pager | wc -l 2>/dev/null || true) + if [[ -z "$FAILED_CNT" || "$FAILED_CNT" -eq 0 ]]; then + state="${aCOLOUR[WEAK]}No Services Failed${COLOUR_RESET}" + else + state="${aCOLOUR[ALERT]}$FAILED_CNT Service(s) Failed${COLOUR_RESET}" + fi + echo "$state" + } + + Get_Fail2Ban_Status() + { + Get_Fail2Ban_Status_Raw() + { + if command -v fail2ban-client >/dev/null 2>&1; then + if [[ "$(id -u)" = "0" ]]; then + f2b_output="$(fail2ban-client banned 2>/dev/null)" + else + f2b_output="$(sudo -n fail2ban-client banned 2>/dev/null || return 2)" + [[ $? = 2 ]] && return 2 + fi + + if ! command -v sort >/dev/null 2>&1; then + sort() { cat; } # fallback if sort is not available, simply passes input through + fi + + # get banned IP address count + IP_addrs="$(echo "$f2b_output" | grep -o '\[[^]]*\]' | grep -Eo "$IP4_re" | sort -u)" + count_banned_ips="$(echo "$IP_addrs" | wc -l)" # | grep -cve '^[[:space:]]*$')" + echo "$count_banned_ips" + + return 0 + else + # fail2ban-client command is not available + return 1 + fi + } + + count_banned_ips=$(Get_Fail2Ban_Status_Raw) + code=$? + + case $code in + 0) + if [[ $count_banned_ips -eq 0 ]]; then + # zero bans can be suspicious - something may not be working correctly + state="${aCOLOUR[HIGHLIGHT]}No IP(s) Banned${COLOUR_RESET}" + else + if [[ $count_banned_ips -gt 20 ]]; then + # high number of bans + state="${aCOLOUR[ALERT]}$count_banned_ips IP(s) Banned${COLOUR_RESET}" + else + # low number of bans + state="${aCOLOUR[WEAK]}$count_banned_ips IP(s) Banned${COLOUR_RESET}" + fi + fi + ;; + 1) + state="${aCOLOUR[ALERT]}fail2ban-client not available${COLOUR_RESET}" + ;; + 2) + state="${aCOLOUR[HIGHLIGHT]}NOPASSWD sudo required to obtain Fail2Ban status${COLOUR_RESET}" + ;; + *) + state="${aCOLOUR[ALERT]}Unknown state, code: $code count: $count_banned_ips${COLOUR_RESET}" + ;; + esac + + echo "$state" + } + + # Print public IP address and location info + # - Optional arguments: + # -t : Set timeout in seconds, supports floats, default: 3 + # -n : Run curl in the given network namespace, e.g. to obtain the WAN IP of a specific + # network namespace, default: empty (no namespace = the main default namespace) + BANNER_GET_WAN_IP() + { + # Defaults + local timeout=3 + local netns="" + # Inputs + while (( $# )) + do + case $1 in + '-t') shift; { (( ${1/.} )) && timeout=$1; } || { G_DIETPI-NOTIFY 1 "Invalid timeout \"$1\", aborting..."; return 1; };; + '-n') shift; netns="${1:-}";; + *) G_DIETPI-NOTIFY 1 "Invalid argument \"$1\", aborting..."; return 1;; + esac + shift + done + + local -a ns_cmd_prefix=() + if [[ -n "$netns" ]]; then + if [[ "$(id -u)" -ne "0" ]]; then + # add sudo if not root + ns_cmd_prefix+=(sudo -n) + fi + # add netns prefix + ns_cmd_prefix+=(ip netns exec "$netns") + fi + + "${ns_cmd_prefix[@]}" curl -sSfLm "$timeout" 'https://dietpi.com/geoip' 2>&1 + } + + Get_Network_Usage() + { + # Common error substrings to detect timeouts / DNS failures (lowercase for case-insensitive check) + local err_re='timed out|timeout|could not resolve|not known|unknown' + + DATA_USAGE(){ + local netns="$1" + + # Use an array prefix so we can run commands either in a netns + # (ip netns exec ...) or the default namespace, and with sudo if needed + local -a ns_cmd_prefix=() + if [[ -n "$netns" ]]; then + if [[ "$(id -u)" -ne "0" ]]; then + # add sudo if not root + ns_cmd_prefix+=(sudo -n) + fi + # add netns prefix + ns_cmd_prefix+=(ip netns exec "$netns") + fi + + # Query interface list once inside the (optional) namespace and match names + mapfile -t observed_ifaces < <("${ns_cmd_prefix[@]}" ip -o link show 2>/dev/null | awk -F': ' '{print $2}') + + for IFACE in "${observed_ifaces[@]}"; do + # strip kernel-style peer suffix (e.g. eth0@if8) and use base name + local ifname="${IFACE%%@*}" + # skip loopback + [[ "$ifname" == lo ]] && continue + + case "$ifname" in + # wired / predictable names + eth*|en*|lan*) : ;; + # wireless + wlan*|wl*) : ;; + # vpn/tunnel/virtual + tun*|wg*|vpn*) : ;; + *) continue ;; + esac + + local RX="N/A" TX="N/A" rx tx ip_addr ip_addr_cidr + + # Get RX data + rx=$("${ns_cmd_prefix[@]}" cat "/sys/class/net/$ifname/statistics/rx_bytes" 2>/dev/null || :) + [[ $rx =~ ^[0-9]+$ ]] && RX="$(awk -v v="$rx" 'BEGIN{printf "%.2f", v/(1024^3)}') GiB" + + # Get TX data + tx=$("${ns_cmd_prefix[@]}" cat "/sys/class/net/$ifname/statistics/tx_bytes" 2>/dev/null || :) + [[ $tx =~ ^[0-9]+$ ]] && TX="$(awk -v v="$tx" 'BEGIN{printf "%.2f", v/(1024^3)}') GiB" + + # Get IP address (IPv4) for this interface in this namespace. If multiple are present, just take the first. If none, show "- No IP -" + ip_addr=" - No IP - " + ip_addr_cidr=$("${ns_cmd_prefix[@]}" ip -o -4 addr show dev "$ifname" scope global 2>/dev/null | awk '{print $4}' | head -n1 | grep -E "$IP4_re_cidr") + [[ -n "$ip_addr_cidr" ]] && ip_addr=${ip_addr_cidr%%/*} + + if [[ "$RX" != "N/A" || "$TX" != "N/A" ]]; then + printf "%s %s%-20s %s%s TX= %11s RX= %11s%s\n" \ + "$CLI_BULLET" "${aCOLOUR[WEAK]}" "$ip_addr ($ifname)" "$CLI_SEPARATOR" "${aCOLOUR[WEAK]}" "$TX" "$RX" "$COLOUR_RESET" + fi + done + } + + mapfile -t namespaces < <(ip netns ls | awk 'NF>1 { print $1 }') + # Prepend an empty entry so the default (root) namespace can be handled in the loop + namespaces=( "" "${namespaces[@]}" ) + + for ns in "${namespaces[@]}"; do + raw_ns="${ns:-default}" + display_ns="$(G_TRUNCATE_MID "$raw_ns" 24)" + + IP=$(BANNER_GET_WAN_IP -t 1 -n "$ns" 2>&1) + + if [[ $IP =~ $IP4_re ]]; then + # Valid IP detected, keep as-is and print with namespace + + # # Extract just the IP portion (use BASH_REMATCH[0]). Removes the geographic info. + # IP="${BASH_REMATCH[0]}" + + printf " %s%s [%s]%s\n" \ + "${aCOLOUR[ALT]}" "$IP" "$display_ns" "$COLOUR_RESET" + + elif [[ -z $IP ]]; then + # If the IP is empty, it likely means the namespace is disconnected or has no internet access. Print status message. + printf "%s %s %s %s%s%s\n" \ + "${aCOLOUR[ALERT]}" "Disconnected" "$CLI_SEPARATOR" "${aCOLOUR[STRONG]}" "$display_ns" "$COLOUR_RESET" + + else + if [[ $IP =~ $err_re ]]; then + # Treat known curl/network error messages as timeout/DNS failures + IP="Timeout/DNS Failure" + fi + + # If the IP contains an unknown or error message, print it with the namespace + printf " %s%s [%s]%s\n" \ + "${aCOLOUR[HIGHLIGHT]}" "$IP" "$display_ns" "$COLOUR_RESET" + fi + + DATA_USAGE "$ns" + done + } + + Print_Custom_Commands() + { + for i in "${!aCUSTOM_COMMANDS[@]}"; do + local cmd="${aCUSTOM_COMMANDS[$i]}" + if [[ -n "$cmd" ]]; then + output="$(eval "$cmd" 2>&1)" + status=$? + if [[ $status -ne 0 ]]; then + printf '%b\n' "${aCOLOUR[ALERT]}Error executing CUSTOM_COMMAND[$((i))] Exit Code: ${status}${COLOUR_RESET}" + else + printf '%b\n' "$output" + fi + fi + done + } + + Get_Disk_Usage() + { + # If no arguments are provided, default to '"/" "/mnt/*"'. + if (( $# > 0 )); then + aDISK_SPACE_PATTERNS=("$@") + fi + + # TODO: is there a more efficient reader than df (stat?) + # Use df's output to parse numeric fields (kB units) for mounts matching the supplied patterns + length=0 + results=() + while read -r size_kb used_kb avail_kb mnt_target; do + matched=0 + basename= + for pattern in "${aDISK_SPACE_PATTERNS[@]}"; do + # If the user supplies a pattern prefixed with 're:' treat it as regex + if [[ "$pattern" == re:* ]]; then + re="${pattern#re:}" + if [[ $mnt_target =~ $re ]]; then + matched=1 + basename=$(basename "$mnt_target") + break + fi + else + # Otherwise, treat it as a shell glob pattern and match using "case" + # shellcheck disable=SC2254 # disable shellcheck for $pattern in case statement + case "$mnt_target" in + $pattern) + matched=1 + basename=$(basename "$mnt_target") + break + ;; + *) ;; + esac + fi + done + if (( matched )); then + len=${#basename} + if (( len > length )); then + length=$len + fi + results+=("$size_kb|$used_kb|$avail_kb|$mnt_target|$basename") + fi + done < <(df -k --output=size,used,avail,target | tail -n +2) + + # Print results with aligned columns, converting kB to GB and calculating percentage used + for entry in "${results[@]}"; do + IFS='|' read -r size_kb used_kb avail_kb mnt_target name <<< "$entry" + + local values_count=0 + # Do all math in kB, convert to GB for display and round to 1 decimal + if [[ "$size_kb" =~ ^[0-9]+$ ]]; then + values_count=$((values_count+1)) + size_gb=$(awk "BEGIN {printf \"%.1f \", $size_kb/1024/1024}") + else + if [[ "$used_kb" =~ ^[0-9]+$ ]] && [[ "$avail_kb" =~ ^[0-9]+$ ]]; then + # If used and available are available, calculate size as used + available + size_kb=$((used_kb+avail_kb)) + size_gb=$(awk "BEGIN {printf \"%.1f \", ($size_kb)/1024/1024}") + else + size_gb="???" + fi + fi + + if [[ "$used_kb" =~ ^[0-9]+$ ]]; then + values_count=$((values_count+1)) + used_gb=$(awk "BEGIN {printf \"%.1f \", $used_kb/1024/1024}") + else + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$avail_kb" =~ ^[0-9]+$ ]]; then + # If size and available are available, calculate used as size - available + used_kb=$((size_kb-avail_kb)) + used_gb=$(awk "BEGIN {printf \"%.1f \", ($used_kb)/1024/1024}") + else + used_gb="???" + fi + fi + + if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then + values_count=$((values_count+1)) + avail_gb=$(awk "BEGIN {printf \"%.1f \", $avail_kb/1024/1024}") + else + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$used_kb" =~ ^[0-9]+$ ]]; then + # If size and used are available, calculate available as size - used + avail_kb=$((size_kb-used_kb)) + avail_gb=$(awk "BEGIN {printf \"%.1f \", ($avail_kb)/1024/1024}") + else + avail_gb="???" + fi + fi + + # Calculate percentage used based on available data: + if [[ "$values_count" -ge 2 ]]; then + # this should match the `df` output + perc=$(awk "BEGIN {printf \"%.1f%%\", ($used_kb/($used_kb+$avail_kb))*100}") + else + perc="?? %" # default unknown value + fi + + # Print aligned columns: {basename} : {used} of {size} ({percent used}) + printf "%s %s%-${length}s %s%s %8sGiB of %8sGiB (%6b)%s\n" \ + "$CLI_BULLET" "${aCOLOUR[ALT]}" "$name" "$CLI_SEPARATOR" "${aCOLOUR[WEAK]}" "$used_gb" "$size_gb" "$perc" "$COLOUR_RESET" + done + + } + + Get_VPN_Status() + { + local state="$( /boot/dietpi/dietpi-vpn status 2>&1 )" + case "$state" in + Connected*) state="${aCOLOUR[WEAK]}$state${COLOUR_RESET}" ;; + Disconnected*) state="${aCOLOUR[ALERT]}$state${COLOUR_RESET}" ;; + *) state="${aCOLOUR[HIGHLIGHT]}$state${COLOUR_RESET}" ;; + esac + echo "$state" } Print_Banner_raw() @@ -275,63 +801,71 @@ $GREEN_LINE" # Large Format Hostname # shellcheck disable=SC1091 - (( ${aENABLED[14]} )) && . /boot/dietpi/func/dietpi-print_large "$(&1)" + (( ${aENABLED[uptime]} )) && Print_Item_State "${aDESCRIPTION[uptime]}" "$(uptime -p 2>&1)" # Linux kernel version - (( ${aENABLED[20]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[20]} $GREEN_SEPARATOR $(uname -r 2>&1)" + (( ${aENABLED[kernel]} )) && Print_Item_State "${aDESCRIPTION[kernel]}" "$(uname -r 2>&1)" # CPU temp - (( ${aENABLED[2]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[2]} $GREEN_SEPARATOR $(print_full_info=1 G_OBTAIN_CPU_TEMP 2>&1)" + (( ${aENABLED[cpu_temp]} )) && Print_Item_State "${aDESCRIPTION[cpu_temp]}" "$(print_full_info=1 G_OBTAIN_CPU_TEMP 2>&1)" # RAM usage - (( ${aENABLED[17]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[17]} $GREEN_SEPARATOR $(free -b | mawk 'NR==2 {CONVFMT="%.0f"; print $3/1024^2" of "$2/1024^2" MiB ("$3/$2*100"%)"}')" + (( ${aENABLED[ram_usage]} )) && Print_Item_State "${aDESCRIPTION[ram_usage]}" "$(free -b | mawk 'NR==2 {CONVFMT="%.0f"; print $3/1024^2" of "$2/1024^2" MiB ("$3/$2*100"%)"}')" # Load average - (( ${aENABLED[18]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[18]} $GREEN_SEPARATOR $(mawk '{print $1 ", " $2 ", " $3}' /proc/loadavg) ($(nproc) cores)" + (( ${aENABLED[load_average]} )) && Print_Item_State "${aDESCRIPTION[load_average]}" "$(mawk '{print $1 ", " $2 ", " $3}' /proc/loadavg) ($(nproc) cores)" # Hostname - (( ${aENABLED[3]} && ! ${aENABLED[14]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[3]} $GREEN_SEPARATOR $(&1)" + (( ${aENABLED[nis_domainname]} )) && Print_Item_State "${aDESCRIPTION[nis_domainname]}" "$(hostname -y 2>&1)" # LAN IP - Print_Local_Ip + (( ${aENABLED[lan_ip]} )) && Print_Item_State "${aDESCRIPTION[lan_ip]}" "$(Get_Local_Ip 2>&1)" # WAN IP + location info - (( ${aENABLED[6]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[6]} $GREEN_SEPARATOR $(G_GET_WAN_IP 2>&1)" + (( ${aENABLED[wan_ip]} )) && Print_Item_State "${aDESCRIPTION[wan_ip]}" "$(BANNER_GET_WAN_IP 2>&1)" # DietPi-VPN connection status - (( ${aENABLED[13]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[13]} $GREEN_SEPARATOR $(/boot/dietpi/dietpi-vpn status 2>&1)" + (( ${aENABLED[vpn_status]} )) && Print_Item_State "${aDESCRIPTION[vpn_status]}" "$(Get_VPN_Status 2>&1)" # Disk usage (RootFS) - (( ${aENABLED[7]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[7]} $GREEN_SEPARATOR $(df -h --output=used,size,pcent / | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" + (( ${aENABLED[disk_rootfs]} )) && echo -e "$CLI_BULLET ${aCOLOUR[STRONG]}${aDESCRIPTION[disk_rootfs]} $CLI_SEPARATOR $(df -h --output=used,size,pcent / | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" # Disk usage (DietPi userdata) - (( ${aENABLED[8]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[8]} $GREEN_SEPARATOR $(df -h --output=used,size,pcent /mnt/dietpi_userdata | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" + (( ${aENABLED[disk_userdata]} )) && echo -e "$CLI_BULLET ${aCOLOUR[STRONG]}${aDESCRIPTION[disk_userdata]} $CLI_SEPARATOR $(df -h --output=used,size,pcent /mnt/dietpi_userdata | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" # Weather - (( ${aENABLED[9]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[9]} $GREEN_SEPARATOR $(curl -sSfLm 3 'https://wttr.in/?format=4' 2>&1)" + (( ${aENABLED[weather]} )) && Print_Item_State "${aDESCRIPTION[weather]}" "$(curl -sSfLm 3 'https://wttr.in/?format=4' 2>&1)" # Let's Encrypt cert status - if (( ${aENABLED[16]} )) - then - echo -en "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[16]} $GREEN_SEPARATOR " - if [[ $EUID == 0 ]] - then - # Running as root - Print_Cert_Status 2>&1 - else - # Running as non-root: Fail silently without NOPASSWD to avoid password prompt, but print info instead - sudo -n dash -c "$(declare -f Print_Cert_Status); Print_Cert_Status 2>&1" 2> /dev/null || echo 'NOPASSWD sudo required to obtain cert status' - fi - fi - # Custom - (( ${aENABLED[10]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[10]} $GREEN_SEPARATOR $(bash "$FP_CUSTOM" 2>&1)" + (( ${aENABLED[letsencrypt]} )) && Print_Item_State "Let's Encrypt Cert" "$(Get_Cert_Status 2>&1)" + # Systemd status + (( ${aENABLED[systemd_status]} )) && Print_Item_State "${aDESCRIPTION[systemd_status]}" "$(Get_Systemd_Status 2>&1)" + # Fail2Ban status + (( ${aENABLED[fail2ban_status]} )) && Print_Item_State "${aDESCRIPTION[fail2ban_status]}" "$(Get_Fail2Ban_Status 2>&1)" + + # Network usage by namespace + # %b required so that percent symbols are not interpreted as format specifiers by printf + (( ${aENABLED[network_usage]} )) && Print_Subheader "Network Traffic" && printf '%b\n' "$(Get_Network_Usage 2>&1)" + # Disk Usage + (( ${aENABLED[disk_usage]} )) && Print_Subheader "Disk Usage" && printf '%b\n' "$(Get_Disk_Usage "${aDISK_SPACE_PATTERNS[@]}" 2>&1)" + + # Custom commands + (( ${aENABLED[custom_commands]} )) && Print_Subheader "Custom Commands" && Print_Custom_Commands + + # Separator line + (( ${aENABLED[useful_commands]} || ${aENABLED[motd]} || ${aENABLED[print_credits]} )) && echo -e "$CLI_LINE" + # MOTD - if (( ${aENABLED[12]} )) + if (( ${aENABLED[motd]} )) then local motd fp_motd='/run/dietpi/.dietpi_motd' [[ -f $fp_motd ]] || curl -sSfLm 3 'https://dietpi.com/motd' -o "$fp_motd" + # disable shellcheck for non-constant `source` (`.`) # shellcheck disable=SC1090 - [[ -f $fp_motd ]] && . "$fp_motd" &> /dev/null && [[ $motd ]] && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[12]} $GREEN_SEPARATOR $motd" + [[ -f $fp_motd ]] && . "$fp_motd" &> /dev/null && [[ $motd ]] && Print_Item_State "${aDESCRIPTION[motd]}" "$motd" fi - echo -e "$GREEN_LINE\n" + echo -e "$CLI_LINE\n" - (( ${aENABLED[15]} )) && Print_Credits + (( ${aENABLED[print_credits]} )) && Print_Credits Print_Updates - (( ${aENABLED[11]} )) && Print_Useful_Commands + (( ${aENABLED[useful_commands]} )) && Print_Useful_Commands + + # Notify user if prefs were migrated from legacy numeric indices during this run + (( DIETPI_BANNER_MIGRATED_DURING_RUN )) && echo -e " ${aCOLOUR[HIGHLIGHT]}Banner needs to be migrated, run dietpi-banner and validate your settings.$COLOUR_RESET" } Print_Banner() @@ -339,7 +873,7 @@ $GREEN_LINE" G_TERM_CLEAR # Pipe to awk script for word-wrap if enabled - if (( ${aENABLED[19]} )) + if (( ${aENABLED[word_wrap]} )) then Print_Banner_raw | mawk -v "MAXCOL=$(tput cols)" -v "INDENT_TYPE=$BW_INDENT_TYPE" -v "INDENT_FIXED=$BW_INDENT_FIXED" -f "$FP_BANNERWRAP_AWK" else @@ -350,39 +884,73 @@ $GREEN_LINE" Menu_Main() { G_WHIP_CHECKLIST_ARRAY=() - for i in "${!aDESCRIPTION[@]}" + for key in "${MENU_ITEMS[@]}" do local state='off' - (( ${aENABLED[$i]:=0} == 1 )) && state='on' - G_WHIP_CHECKLIST_ARRAY+=("$i" "${aDESCRIPTION[$i]}" "$state") + (( ${aENABLED[$key]:=0} == 1 )) && state='on' + G_WHIP_CHECKLIST_ARRAY+=("$key" "${aDESCRIPTION[$key]}" "$state") done + G_WHIP_CHECKLIST_ENUM=1 # set the menu to use integer indices for the display G_WHIP_CHECKLIST "Please (de)select options via spacebar to be shown in the $G_PROGRAM_NAME:" || return 0 + G_WHIP_CHECKLIST_ENUM=0 - for i in "${!aDESCRIPTION[@]}" + for key in "${MENU_ITEMS[@]}" do - aENABLED[$i]=0 + aENABLED[$key]=0 done - for i in $G_WHIP_RETURNED_VALUE + for selection in $G_WHIP_RETURNED_VALUE do - aENABLED[$i]=1 + aENABLED[$selection]=1 - if (( $i == 10 )) + if [[ "$selection" == "custom_commands" ]] then - # Custom entry - [[ -f $FP_CUSTOM ]] && read -r G_WHIP_DEFAULT_ITEM < "$FP_CUSTOM" || G_WHIP_DEFAULT_ITEM='echo '\''Hello World!'\' - G_WHIP_INPUTBOX 'You have chosen to show a custom entry in the banner. -Please enter the desired command here.\n -NB: It is executed as bash script, so it needs to be in bash compatible syntax. - For multi-line or non-bash scripts, keep it separate and only add the script call here.' || continue + # ignore check for single/double quote expansion issue; unassigned var is referenced; + # shellcheck disable=SC2016,SC2154 + example_command='echo -e "$CLI_BULLET ${aCOLOUR[ALERT]}Hello ${aCOLOUR[GOOD]}World! ${COLOUR_RESET} :)"\n\n-- or with the standard format --\n\nPrint_Item_State "Description" "value"' - echo "$G_WHIP_RETURNED_VALUE" > "$FP_CUSTOM" + # Manage custom commands list + while true; do + G_WHIP_MENU_ARRAY=( 'Add' 'Add a new command' 'Edit' 'Edit an existing command' 'Remove' 'Remove a command' 'Done' 'Finish custom commands configuration' ) + G_WHIP_DEFAULT_ITEM='Done' + G_WHIP_MENU 'Manage custom banner commands.\n\n!CAUTION!\nThese commands are executed with the same privileges as dietpi-banner, and DO NOT have a time limit :' || break + case "$G_WHIP_RETURNED_VALUE" in + Add) + G_WHIP_INPUTBOX "Enter new command, for example :\n$example_command" || continue + aCUSTOM_COMMANDS+=("$G_WHIP_RETURNED_VALUE") + ;; + Edit) + if (( ${#aCUSTOM_COMMANDS[@]} == 0 )); then G_WHIP_MSG 'No commands to edit'; continue; fi + G_WHIP_MENU_ARRAY=() + for idx in "${!aCUSTOM_COMMANDS[@]}"; do G_WHIP_MENU_ARRAY+=("$idx" "${aCUSTOM_COMMANDS[$idx]}"); done + G_WHIP_MENU 'Select command to edit:' || continue + sel=$G_WHIP_RETURNED_VALUE + G_WHIP_DEFAULT_ITEM=${aCUSTOM_COMMANDS[$sel]} + G_WHIP_INPUTBOX "Edit command, for example :\n$example_command" || continue + aCUSTOM_COMMANDS[$sel]="$G_WHIP_RETURNED_VALUE" + ;; + Remove) + if (( ${#aCUSTOM_COMMANDS[@]} == 0 )); then G_WHIP_MSG 'No commands to remove'; continue; fi + G_WHIP_MENU_ARRAY=() + for idx in "${!aCUSTOM_COMMANDS[@]}"; do G_WHIP_MENU_ARRAY+=("$idx" "${aCUSTOM_COMMANDS[$idx]}"); done + G_WHIP_MENU 'Select command to remove:' || continue + sel=$G_WHIP_RETURNED_VALUE + unset 'aCUSTOM_COMMANDS[$sel]' + # reindex array + aCUSTOM_COMMANDS=("${aCUSTOM_COMMANDS[@]}") + ;; + Done) + break + ;; + *) ;; + esac + done - G_WHIP_DEFAULT_ITEM=${aDESCRIPTION[10]} - G_WHIP_INPUTBOX 'Please enter a meaningful name to be shown in front of your custom command output:' && aDESCRIPTION[10]=$G_WHIP_RETURNED_VALUE + # If no commands are configured, disable the custom commands option + (( ${#aCUSTOM_COMMANDS[@]} == 0 )) && aENABLED[custom_commands]=0 - elif (( $i == 19 )) + elif [[ "$selection" == "word_wrap" ]] then # Banner word-wrap options G_WHIP_MENU_ARRAY=( @@ -397,10 +965,63 @@ NB: It is executed as bash script, so it needs to be in bash compatible syntax. G_WHIP_DEFAULT_ITEM=$BW_INDENT_FIXED G_WHIP_INPUTBOX_REGEX='^[1-9][0-9]*$' G_WHIP_INPUTBOX_REGEX_TEXT='be a number' G_WHIP_INPUTBOX 'Please set the fixed offset column to indent word-wrapped lines to:' && BW_INDENT_FIXED=$G_WHIP_RETURNED_VALUE + + elif [[ "$selection" == "disk_usage" ]] + then + # ignore check for single/double quote expansion issue; single/double quotes for glob; + # shellcheck disable=SC2016,SC2086 + pattern_hint="The matching mount 'target's returned by the 'df' command will be reported. +To use regex, start the pattern with 're:', otherwise shell globbing is used. +For example: '/mnt/*' or 're:^/mnt/.*/*$'" + + # Manage aDISK_SPACE_PATTERNS list + while true; do + G_WHIP_MENU_ARRAY=( 'Add' 'Add a new pattern' 'Edit' 'Edit an existing pattern' 'Remove' 'Remove a pattern' 'Done' 'Finish pattern configuration' ) + G_WHIP_DEFAULT_ITEM='Done' + G_WHIP_MENU "Manage disk space match patterns. $pattern_hint" || break + case "$G_WHIP_RETURNED_VALUE" in + Add) + G_WHIP_INPUTBOX "Enter new pattern :\n$pattern_hint" || continue + aDISK_SPACE_PATTERNS+=("$G_WHIP_RETURNED_VALUE") + ;; + Edit) + if (( ${#aDISK_SPACE_PATTERNS[@]} == 0 )); then G_WHIP_MSG 'No patterns to edit'; continue; fi + G_WHIP_MENU_ARRAY=() + for idx in "${!aDISK_SPACE_PATTERNS[@]}"; do G_WHIP_MENU_ARRAY+=("$idx" "${aDISK_SPACE_PATTERNS[$idx]}"); done + G_WHIP_MENU 'Select pattern to edit :' || continue + sel=$G_WHIP_RETURNED_VALUE + G_WHIP_DEFAULT_ITEM=${aDISK_SPACE_PATTERNS[$sel]} + G_WHIP_INPUTBOX "Edit pattern :\n$pattern_hint" || continue + aDISK_SPACE_PATTERNS[$sel]="$G_WHIP_RETURNED_VALUE" + ;; + Remove) + if (( ${#aDISK_SPACE_PATTERNS[@]} == 0 )); then G_WHIP_MSG 'No patterns to remove'; continue; fi + G_WHIP_MENU_ARRAY=() + for idx in "${!aDISK_SPACE_PATTERNS[@]}"; do G_WHIP_MENU_ARRAY+=("$idx" "${aDISK_SPACE_PATTERNS[$idx]}"); done + G_WHIP_MENU 'Select pattern to remove :' || continue + sel=$G_WHIP_RETURNED_VALUE + unset 'aDISK_SPACE_PATTERNS[$sel]' + # reindex array + aDISK_SPACE_PATTERNS=("${aDISK_SPACE_PATTERNS[@]}") + ;; + Done) + break + ;; + *) ;; + esac + done + + # If no patterns are configured, disable the disk usage option + (( ${#aDISK_SPACE_PATTERNS[@]} == 0 )) && aENABLED[disk_usage]=0 fi done - [[ -f $FP_CUSTOM ]] || aENABLED[10]=0 + # Before saving, create a backup of the existing prefs if migrating. + # FP_SAVEFILE gets truncated when calling Save() below, so we need to backup first. + if [[ -f "$FP_SAVEFILE" ]] && [[ $DIETPI_BANNER_MIGRATED_DURING_RUN == 1 ]]; then + backup="$FP_SAVEFILE.bak-$(date -Iseconds)" + cp -a "$FP_SAVEFILE" "$backup" || true + fi Save > "$FP_SAVEFILE" } @@ -409,7 +1030,7 @@ NB: It is executed as bash script, so it needs to be in bash compatible syntax. # Main Loop #///////////////////////////////////////////////////////////////////////////////////// case $INPUT in - 0) Print_Header; Print_Local_Ip;; + 0) Print_Header; Print_Item_State "${aDESCRIPTION[lan_ip]}" "$(Get_Local_Ip 2>&1)";; 1) Print_Banner;; '') Menu_Main; Print_Banner;; *) G_DIETPI-NOTIFY 1 "Invalid input \"$*\"\n\nUsage:$USAGE"; exit 1;; @@ -418,4 +1039,4 @@ NB: It is executed as bash script, so it needs to be in bash compatible syntax. #----------------------------------------------------------------------------------- exit 0 #----------------------------------------------------------------------------------- -} +} \ No newline at end of file diff --git a/dietpi/func/dietpi-globals b/dietpi/func/dietpi-globals index b84cf27827..f3a184092d 100644 --- a/dietpi/func/dietpi-globals +++ b/dietpi/func/dietpi-globals @@ -171,6 +171,32 @@ $(ps f -eo pid,user,tty,cmd | grep -i 'dietpi')" && continue } + # Truncate a string in the middle to a maximum length, inserting "..." + # - If the string is shorter than or equal to the maximum length, it is returned unchanged. + # - If the maximum length is less than or equal to 3, the string is truncated to the maximum length without adding "...". + # - E.g. + # For example: + # `G_TRUNCATE_MID "Long text to be shortened by removing the middle" 26` returns "Long text to... the middle" + # `G_TRUNCATE_MID "Long text" 5` returns "L...t" + # `G_TRUNCATE_MID "Text" 3` returns "Tex" + G_TRUNCATE_MID() { + local s="$1"; local max="$2" + local len=${#s} + if (( len <= max )); then + printf '%s' "$s" + return + fi + if (( max <= 3 )); then + printf '%.*s' "$max" "$s" + return + fi + local keep=$((max - 3)) + local pre=$(((keep + 1) / 2)) + local suf=$((keep / 2)) + local start=$((len - suf)) + printf '%s...%s' "${s:0:pre}" "${s:start}" + } + # DietPi-Notify # $1: # -2 = Processing @@ -460,6 +486,7 @@ $grey───────────────────────── # - G_WHIP_NOCANCEL=1 | Optional, hide the cancel button on inputbox, menu and checkbox dialogues # - G_WHIP_MENU_ARRAY | Required for G_WHIP_MENU to set available menu entries, 2 array indices per line: ('item' 'description') # - G_WHIP_CHECKLIST_ARRAY | Required for G_WHIP_CHECKLIST set available checklist options, 3 array indices per line: ('item' 'description' 'on'/'off') + # - G_WHIP_CHECKLIST_ENUM=1 | Optional, if set, the checklist will be displayed enumerated with numeric tags rather than the provided tags # Output: # - G_WHIP_RETURNED_VALUE | Returned value from inputbox/menu/checklist based whiptail items @@ -805,6 +832,7 @@ $grey───────────────────────── # G_WHIP_CHECKLIST "message" # - Prompt user to select multiple options from G_WHIP_CHECKLIST_ARRAY and save choice to G_WHIP_RETURNED_VALUE # - Exit code: 0=selection done, else=user cancelled or noninteractive + # - G_WHIP_CHECKLIST_ENUM=1 | Optional, if set, the checklist will be displayed enumerated with numeric tags rather than the provided tags G_WHIP_CHECKLIST() { local result=1 @@ -815,10 +843,48 @@ $grey───────────────────────── local WHIP_ERROR WHIP_BACKTITLE WHIP_SCROLLTEXT WHIP_SIZE_X WHIP_SIZE_Y WHIP_SIZE_Z WHIP_MESSAGE=$* NOCANCEL=() [[ $G_WHIP_NOCANCEL == 1 ]] && NOCANCEL=('--nocancel') G_WHIP_BUTTON_OK_TEXT=${G_WHIP_BUTTON_OK_TEXT:-Confirm} + + # Places to save original checklist and default + local orig_checklist=() original_default_item="$G_WHIP_DEFAULT_ITEM" + + #### + # If G_WHIP_CHECKLIST_ENUM=1, the checklist will be enumerated with numeric tags for whiptail, + # while preserving the original tags for mapping backwards after the checklist selection. + if [[ ${G_WHIP_CHECKLIST_ENUM:-0} == 1 && ${#G_WHIP_CHECKLIST_ARRAY[@]} -gt 0 ]] + then + # save the original checklist + orig_checklist=("${G_WHIP_CHECKLIST_ARRAY[@]}") + + # Build numeric-tagged checklist for whiptail while preserving original tags + local -a __tmp=() __idx=0 __map=() + for ((i=0;i<${#orig_checklist[@]};i+=3)); do + __map[$__idx]="${orig_checklist[$i]}" + __tmp+=("$__idx" "${orig_checklist[$i+1]}" "${orig_checklist[$i+2]}") + [[ -n "$original_default_item" && "$original_default_item" == "${orig_checklist[$i]}" ]] && G_WHIP_DEFAULT_ITEM=$__idx + ((__idx++)) + done + G_WHIP_CHECKLIST_ARRAY=("${__tmp[@]}") + fi + G_WHIP_INIT 3 # shellcheck disable=SC2086 G_WHIP_RETURNED_VALUE=$(whiptail ${G_PROGRAM_NAME:+--title "$G_PROGRAM_NAME"} --backtitle "$WHIP_BACKTITLE | Use spacebar to toggle selection" --checklist "$WHIP_MESSAGE" --separate-output --ok-button "$G_WHIP_BUTTON_OK_TEXT" --cancel-button "$G_WHIP_BUTTON_CANCEL_TEXT" "${NOCANCEL[@]}" --default-item "$G_WHIP_DEFAULT_ITEM" $WHIP_SCROLLTEXT "$WHIP_SIZE_Y" "$WHIP_SIZE_X" "$WHIP_SIZE_Z" -- "${G_WHIP_CHECKLIST_ARRAY[@]}" 3>&1 1>&2 2>&3-) result=$? + + # If enumeration was used, map numeric selections back to original tags + if [[ ${G_WHIP_CHECKLIST_ENUM:-0} == 1 && ${#orig_checklist[@]} -gt 0 && -n "$G_WHIP_RETURNED_VALUE" ]] + then + local sel mapped=() + for sel in $G_WHIP_RETURNED_VALUE; do + mapped+=("${__map[$sel]}") + done + G_WHIP_RETURNED_VALUE="${mapped[*]}" + # restore originals + G_WHIP_CHECKLIST_ARRAY=("${orig_checklist[@]}") + G_WHIP_DEFAULT_ITEM="$original_default_item" + unset -v __map __tmp __idx sel mapped + fi + G_WHIP_RETURNED_VALUE=$(echo -e "$G_WHIP_RETURNED_VALUE" | tr '\n' ' ') else G_WHIP_RETURNED_VALUE=$G_WHIP_DEFAULT_ITEM @@ -1475,21 +1541,29 @@ Press any key to continue...' # Print public IP address and location info # - Optional arguments: # -t : Set timeout in seconds, supports floats, default: 3 + # -n : Run curl in the given network namespace, e.g. to obtain the WAN IP of a specific + # network namespace, default: empty (no namespace = the main default namespace) G_GET_WAN_IP() { # Defaults local timeout=3 + local ns="" # Inputs while (( $# )) do - # shellcheck disable=SC2015 case $1 in - '-t') shift; (( ${1/.} )) && timeout=$1 || { G_DIETPI-NOTIFY 1 "Invalid timeout \"$1\", aborting..."; return 1; };; + '-t') shift; { (( ${1/.} )) && timeout=$1; } || { G_DIETPI-NOTIFY 1 "Invalid timeout \"$1\", aborting..."; return 1; };; + '-n') shift; ns="${1:-}";; *) G_DIETPI-NOTIFY 1 "Invalid argument \"$1\", aborting..."; return 1;; esac shift done - curl -sSfLm "$timeout" 'https://dietpi.com/geoip' + + if [[ -n "$ns" ]]; then + ip netns exec "$ns" curl -sSfLm "$timeout" 'https://dietpi.com/geoip' 2>&1 + else + curl -sSfLm "$timeout" 'https://dietpi.com/geoip' 2>&1 + fi } # $1 = directory to test permissions support