From 01ec151ade68ce176226f1e8bbe469262ee6963a Mon Sep 17 00:00:00 2001 From: fatalerrors Date: Tue, 13 Oct 2026 13:55:24 +0200 Subject: [PATCH] net bug fix, myextapi now service agnostic --- README.md | 4 +- doc/CHANGELOG.md | 10 ++++ profile.d/net.sh | 143 ++++++++++++++++++++++++++++++++++------------- 3 files changed, 117 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 6c67699..c36d9d5 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ A bar-style prompt showing current time, execution time of the last command | `mcd` | filefct | Create a directory and immediately move into it | | `mdcat` | disp | Render Markdown files (or stdin) in terminal with colors, inline formatting, tables and framed code blocks; honours `NO_COLOR` | | `meteo` | info | Display weather forecast for the configured or given city | -| `myextip` | net | Get information about your public IP address | +| `myextip` | net | Get information about your public IP address (IP, ISP, location, coordinates, AS, timezone) from an HTTPS lookup service; needs `jq` for the formatted output, `-R` shows the raw JSON | | `get_pkgmgr` | packages | Detect the active package manager of the running distribution (`apt`, `dnf`, `yum`, `zypper`, `pacman`, `apk`, `portage`, `xbps`, `nix`) | | `pinfo` | info | Display system information read from `/proc` and `/sys` only | | `pkgf` | packages | Find which installed package owns a given file (distro-aware via `get_pkgmgr`) | @@ -307,7 +307,7 @@ change the default without having to pass flags every time. | --- | --- | --- | | `DWL_PREFERRED_TOOL` | _(empty)_ | Force `dwl` to use `curl`, `wget`, or `fetch` (auto-detected when unset) | | `DWL_DEFAULT_RESUME` | `0` | Enable `dwl` resume mode by default (`1`/`true`/`yes`/`on`); applies to file downloads | -| `MYEXTIP_DEFAULT_URL` | `https://ip-api.com/json` | API endpoint used by `myextip` | +| `MYEXTIP_DEFAULT_URL` | `https://ipinfo.io/json` | HTTPS JSON API used by `myextip`. `https://ipwho.is/` is also supported, as is the (HTTP-only on its free tier) ip-api.com format; a non-HTTPS URL triggers a warning | **`[packages]`** diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index ea68740..b0d0b77 100755 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -96,6 +96,16 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). `setlocale fr_FR` could pick a non-UTF-8 charset; shortcut functions are built without evaluating the raw configuration value, and an alias cannot replace `setlocale` or `setc`. +- `myextip`: now uses the HTTPS `https://ipinfo.io/json` API by default, since + the free ip-api.com tier is HTTP only (HTTPS answers 403); ipwho.is and the + ip-api.com format are still understood, a Timezone line and the postal code + are shown, an unexpected answer is reported instead of printing `jq` errors, + and a non-HTTPS URL gives a warning. +- `net.sh`: `dwl` always returned 0 (the cleanup after the transfer masked + curl's status), so no caller ever saw a failed download; HTTP errors (404, + 403, ...) are now failures instead of saved error pages; `urlencode` + mis-encoded accents under `LC_ALL` and now uses `%20` for spaces; `isipv4` + leaked a variable. - `compress.sh`: option flags and loop variables no longer leak into the interactive shell. diff --git a/profile.d/net.sh b/profile.d/net.sh index 918e6bc..eb90864 100644 --- a/profile.d/net.sh +++ b/profile.d/net.sh @@ -44,7 +44,8 @@ dwl() local timeout="" local resume=0 - case "${DWL_DEFAULT_RESUME,,}" in + local _res="${DWL_DEFAULT_RESUME:-}" + case "${_res,,}" in 1|true|yes|on) resume=1 ;; @@ -116,7 +117,9 @@ dwl() _try_curl() { - local args=(-sL) + # -f: an HTTP error (404, 500, ...) is a failure, not a saved error + # page; -S keeps error messages visible despite -s. + local args=(-fsSL) [[ -n "$timeout" ]] && args+=(--max-time "$timeout" --connect-timeout "$timeout") if [[ -z "$output" ]]; then if (( resume == 1 )); then @@ -155,31 +158,40 @@ dwl() fi } + # Pick the tool: the configured one, otherwise the first available. + # Helpers are removed on every exit path; the exit status of the transfer + # itself is returned (the final "unset -f" must not mask it). + local tool="" if [[ -n "$preferred" ]]; then - command -v "$preferred" >/dev/null 2>&1 || { + if ! command -v "$preferred" >/dev/null 2>&1; then echo "Error: preferred download tool '$preferred' is not installed." >&2 + unset -f _try_curl _try_wget _try_fetch return 1 - } + fi case "$preferred" in - curl) _try_curl ;; - wget) _try_wget ;; - fetch) _try_fetch ;; + curl|wget|fetch) tool="$preferred" ;; *) echo "Error: DWL_PREFERRED_TOOL '$preferred' is not supported (use curl, wget or fetch)." >&2 + unset -f _try_curl _try_wget _try_fetch return 1 ;; esac elif command -v curl >/dev/null 2>&1; then - _try_curl + tool=curl elif command -v wget >/dev/null 2>&1; then - _try_wget + tool=wget elif command -v fetch >/dev/null 2>&1; then - _try_fetch + tool=fetch else echo "Error: No download utility (curl, wget, or fetch) found." >&2 + unset -f _try_curl _try_wget _try_fetch return 1 fi + + local rc=0 + "_try_${tool}" || rc=$? unset -f _try_curl _try_wget _try_fetch + return "$rc" } export -f dwl # ------------------------------------------------------------------------------ @@ -196,10 +208,8 @@ isipv4() # Start with a regex format test (four octets) if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then - local old_ifs=$IFS - IFS='.' - read -r -a ip_arr <<< "$ip" - IFS=$old_ifs + local -a ip_arr=() + IFS='.' read -r -a ip_arr <<< "$ip" # Ensure each octet is between 0 and 255 local oct @@ -325,11 +335,16 @@ export -f isipv6 # ------------------------------------------------------------------------------ -# Encode a string so it can be used as a URL parameter +# Encode a string so it can be used in a URL path or query parameter: every +# byte outside the unreserved set becomes %XX (UTF-8 bytes included, space is +# %20, valid in both a path and a query). # Usage: urlencode urlencode() { - local LANG=C + # LC_ALL (not LANG) is what overrides all the locale variables: with the + # C locale each character is one byte, so multibyte characters are encoded + # byte by byte (é -> %C3%A9) whatever the user's locale is. + local LC_ALL=C local str="$*" local length="${#str}" local i @@ -337,7 +352,6 @@ urlencode() local c="${str:i:1}" case "$c" in [a-zA-Z0-9.~_-]) printf '%s' "$c" ;; - ' ') printf '+' ;; *) printf '%%%02X' "'$c" ;; esac done @@ -416,34 +430,87 @@ myextip() shift done - # Fetch data. Allow overriding endpoint via MYEXTIP_DEFAULT_URL config key. - local api_url="${MYEXTIP_DEFAULT_URL:-https://ip-api.com/json/}" + # Fetch data. The endpoint can be changed with the MYEXTIP_DEFAULT_URL + # configuration key; the default is the HTTPS ipinfo.io JSON API (no key + # needed). ip-api.com is not usable: its free tier is HTTP only. + local api_url="${MYEXTIP_DEFAULT_URL:-https://ipinfo.io/json}" + if [[ $api_url != https://* ]]; then + disp W "The lookup service ($api_url) is not HTTPS: the answer is not protected." + fi local response - if ! response=$(dwl "$api_url"); then + if ! response=$(dwl -t 10 "$api_url"); then disp E "Failed to fetch external IP information from $api_url" return 2 fi - # Parse with jq when available and when raw wasn't requested. The jq filter - # is tolerant to field-name differences between providers (ip-api / ipinfo). - if command -v jq >/dev/null 2>&1 && [[ "$show_raw" != true ]]; then - echo "$response" | jq -r --argjson all "$all" --argjson ip "$show_ip" \ - --argjson isp "$show_isp" --argjson loc "$show_loc" \ - --argjson coord "$show_coord" --argjson as "$show_as" ' - [ - (if $all or $ip then "IP Address : \(.query // .ip)" else empty end), - (if $all or $isp then "ISP : \(.isp // .org)" else empty end), - (if $all or $loc then - ("Location : " + ((.city // "") + (if .city then ", " else "" end) + (if .regionName then .regionName else .region end) + (if .country then ", " + .country else "" end))) - else empty end), - (if $all or $coord then (if (.lat and .lon) then "Coordinates: \(.lat), \(.lon)" elif .loc then "Coordinates: \(.loc)" else empty end) else empty end), - (if $all or $as then "AS : \(.as // .org)" else empty end) - ] | .[]' - else - [[ "$show_raw" != true ]] && disp W "jq is not installed, displaying raw JSON response." - echo "$response" + if [[ "$show_raw" == true ]]; then + printf '%s\n' "$response" + return 0 fi + + if ! command -v jq >/dev/null 2>&1; then + disp W "jq is not installed, displaying raw JSON response." + printf '%s\n' "$response" + return 0 + fi + + # The first jq stage turns the answer of any supported provider into one + # common object, so the display stage below does not care where the data + # comes from: + # ipinfo.io : ip, org "AS ", city, region, country (code), + # loc "lat,lon", timezone (string), postal + # ipwho.is : ip, connection{asn,org,isp}, country (name), latitude, + # longitude, timezone{id}, postal (recognised by .connection) + # ip-api.com: query, isp, as, regionName, lat, lon, timezone, zip + # (recognised by .query) + # A missing field is simply left out of the output. An answer without any + # IP (error message, rate limit, ...) is reported as unexpected. + local out + out=$(printf '%s' "$response" | jq -r \ + --argjson all "$all" --argjson ip "$show_ip" \ + --argjson isp "$show_isp" --argjson loc "$show_loc" \ + --argjson coord "$show_coord" --argjson as "$show_as" ' + def clean: map(select(. != null and . != "")) ; + (if (.connection | type) == "object" then + { ip: .ip, + isp: (.connection.isp // .connection.org), + as: (if .connection.asn then + (["AS\(.connection.asn)", .connection.org] | clean | join(" ")) + else null end), + city: .city, region: .region, country: .country, zip: .postal, + lat: .latitude, lon: .longitude, tz: .timezone.id } + elif .query then + { ip: .query, isp: .isp, as: .as, city: .city, region: .regionName, + country: .country, zip: .zip, lat: .lat, lon: .lon, tz: .timezone } + else + ((.loc // "" | split(",")) as $l + | { ip: .ip, + isp: (.org // null | if . then sub("^AS[0-9]+ +"; "") else . end), + as: .org, city: .city, region: .region, country: .country, + zip: .postal, + lat: ($l[0] // null), lon: ($l[1] // null), tz: .timezone }) + end) as $d + | if $d.ip == null then "unexpected response" | halt_error(3) else + [ + (if ($all or $ip) and $d.ip then "IP Address : \($d.ip)" else empty end), + (if ($all or $isp) and $d.isp then "ISP : \($d.isp)" else empty end), + (if ($all or $loc) then + ([$d.city, $d.region, $d.country] | clean | join(", ") + | select(. != "") + | "Location : \(.)" + (if $d.zip then " (\($d.zip))" else "" end)) + else empty end), + (if ($all or $coord) and $d.lat and $d.lon then "Coordinates : \($d.lat), \($d.lon)" else empty end), + (if ($all or $as) and $d.as then "AS : \($d.as)" else empty end), + (if $all and $d.tz then "Timezone : \($d.tz)" else empty end) + ] | .[] + end' 2>/dev/null) + local rc=$? + if (( rc != 0 )) || [[ -z $out ]]; then + disp E "Unexpected answer from $api_url (use --raw to see it)." + return 3 + fi + printf '%s\n' "$out" } export -f myextip # ------------------------------------------------------------------------------