527 lines
19 KiB
Bash
527 lines
19 KiB
Bash
#!/usr/bin/env bash
|
|
# ------------------------------------------------------------------------------
|
|
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
|
|
# Protected by the BSD3 license. Please read bellow for details.
|
|
#
|
|
# * Redistribution and use in source and binary forms,
|
|
# * with or without modification, are permitted provided
|
|
# * that the following conditions are met:
|
|
# *
|
|
# * Redistributions of source code must retain the above
|
|
# * copyright notice, this list of conditions and the
|
|
# * following disclaimer.
|
|
# *
|
|
# * Redistributions in binary form must reproduce the above
|
|
# * copyright notice, this list of conditions and the following
|
|
# * disclaimer in the documentation and/or other materials
|
|
# * provided with the distribution.
|
|
# *
|
|
# * Neither the name of the copyright holder nor the names
|
|
# * of any other contributors may be used to endorse or
|
|
# * promote products derived from this software without
|
|
# * specific prior written permission.
|
|
# *
|
|
# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
|
# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
|
# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
|
# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
|
# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
|
# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
|
# * OF SUCH DAMAGE.
|
|
# ------------------------------------------------------------------------------
|
|
|
|
# shellcheck disable=SC2317,SC2329 # nested helper functions are called through their name (shellcheck cannot follow them)
|
|
|
|
load_conf "net"
|
|
|
|
# Common helpers, then the ones private to this module (helpers/net.sh)
|
|
# shellcheck source=../helpers/common.sh
|
|
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
|
|
_profile_load_helpers "${BASH_SOURCE[0]}"
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Download a resource using curl, wget, or fetch.
|
|
# Usage: dwl [-t <seconds>] [-r|--resume] <url> [output_file]
|
|
dwl()
|
|
{
|
|
local timeout=""
|
|
local resume=0
|
|
|
|
local _res="${DWL_DEFAULT_RESUME:-}"
|
|
case "${_res,,}" in
|
|
1|true|yes|on)
|
|
resume=1
|
|
;;
|
|
esac
|
|
|
|
# Parse leading options before the URL.
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--help|-h)
|
|
echo "Usage: dwl [-t <seconds>|--timeout <seconds>] [-r|--resume] [--no-resume] <url> [output_file]"
|
|
echo "Downloads a resource using curl, wget, or fetch."
|
|
echo ""
|
|
echo "Arguments:"
|
|
echo " -t, --timeout Maximum time in seconds to wait for the transfer."
|
|
echo " -r, --resume Resume an interrupted download when possible (file output only)."
|
|
echo " --no-resume Disable resume mode even if enabled in config."
|
|
echo " url The full URL to download (http/https/ftp)."
|
|
echo " output_file (Optional) Path to save the file. If omitted, prints to stdout."
|
|
return 0
|
|
;;
|
|
-t|--timeout)
|
|
[[ -z "${2:-}" || ! "${2:-}" =~ ^[0-9]+$ ]] && {
|
|
echo "Error: --timeout requires a positive integer argument." >&2
|
|
return 1
|
|
}
|
|
timeout="$2"
|
|
shift 2
|
|
;;
|
|
-r|--resume)
|
|
resume=1
|
|
shift
|
|
;;
|
|
--no-resume)
|
|
resume=0
|
|
shift
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
-*)
|
|
echo "Error: Unknown option '$1'. Try 'dwl --help'." >&2
|
|
return 1
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
case "${1:-}" in
|
|
"")
|
|
echo "Error: URL argument is missing." >&2
|
|
echo "Try 'dwl --help' for usage." >&2
|
|
return 1
|
|
;;
|
|
http://*|https://*|ftp://*) ;;
|
|
*)
|
|
echo "Error: '$1' does not look like a valid URL. Must start with http://, https://, or ftp://" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
local url="$1"
|
|
local output="${2:-}"
|
|
|
|
# Honour preferred tool from configuration; fall back to auto-detection.
|
|
local preferred="${DWL_PREFERRED_TOOL:-}"
|
|
|
|
_try_curl()
|
|
{
|
|
# -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
|
|
echo "Warning: --resume requires an output file; ignoring resume mode for stdout." >&2
|
|
fi
|
|
curl "${args[@]}" "$url"
|
|
else
|
|
(( resume == 1 )) && args+=(-C -)
|
|
curl "${args[@]}" -o "$output" "$url"
|
|
fi
|
|
}
|
|
|
|
_try_wget()
|
|
{
|
|
local args=(-q)
|
|
[[ -n "$timeout" ]] && args+=(--timeout="$timeout")
|
|
(( resume == 1 )) && args+=(-c)
|
|
if [[ -z "$output" ]]; then
|
|
wget "${args[@]}" -O- "$url"
|
|
else
|
|
wget "${args[@]}" -O "$output" "$url"
|
|
fi
|
|
}
|
|
|
|
_try_fetch()
|
|
{
|
|
local args=()
|
|
[[ -n "$timeout" ]] && args+=(-T "$timeout")
|
|
if (( resume == 1 )); then
|
|
echo "Warning: resume mode is not supported with fetch; continuing without resume." >&2
|
|
fi
|
|
if [[ -z "$output" ]]; then
|
|
fetch "${args[@]}" -o - "$url"
|
|
else
|
|
fetch "${args[@]}" -o "$output" "$url"
|
|
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
|
|
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|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
|
|
tool=curl
|
|
elif command -v wget >/dev/null 2>&1; then
|
|
tool=wget
|
|
elif command -v fetch >/dev/null 2>&1; then
|
|
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
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Determine if parameter is a valid IPv4 address
|
|
# Usage: isipv4 <ip_address>
|
|
isipv4()
|
|
{
|
|
# Set up local variables
|
|
local ip=$1
|
|
[[ -z $ip ]] && return 1
|
|
|
|
# Start with a regex format test (four octets)
|
|
if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
|
local -a ip_arr=()
|
|
IFS='.' read -r -a ip_arr <<< "$ip"
|
|
|
|
# Ensure each octet is between 0 and 255
|
|
local oct
|
|
for oct in "${ip_arr[@]}"; do
|
|
# Reject leading plus/minus or empty entries
|
|
if [[ -z $oct || $oct =~ [^0-9] ]]; then
|
|
[[ -t 1 ]] && disp E "The given parameter is NOT a valid IPv4."
|
|
return 1
|
|
fi
|
|
# 10# forces base-10 so leading-zero octets (08, 09) are not read as octal
|
|
if (( 10#$oct > 255 )); then
|
|
[[ -t 1 ]] && disp E "The given parameter is NOT a valid IPv4."
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
[[ -t 1 ]] && disp I "The given IPv4 is valid."
|
|
return 0
|
|
fi
|
|
|
|
[[ -t 1 ]] && disp E "The given parameter is NOT a valid IPv4."
|
|
return 1
|
|
}
|
|
export -f isipv4
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Determine if parameter is a valid IPv6 address
|
|
# Usage: isipv6 <ip_address>
|
|
# Accepts full and "::"-compressed forms, an embedded IPv4 suffix
|
|
# (e.g. ::ffff:192.0.2.1) and an optional RFC 4007 zone id (e.g. fe80::1%eth0).
|
|
isipv6()
|
|
{
|
|
local ip="$1"
|
|
local valid=0
|
|
|
|
# A single-pass validation; any failing check breaks out with valid=0.
|
|
while true; do
|
|
[[ -z $ip ]] && break
|
|
|
|
# Strip an optional zone identifier (fe80::1%eth0); reject empty/multiple.
|
|
if [[ $ip == *%* ]]; then
|
|
[[ $ip == *%*%* ]] && break
|
|
[[ -z ${ip#*%} ]] && break
|
|
ip="${ip%%[%]*}"
|
|
fi
|
|
|
|
# Reject ':::' and more than one '::' compression.
|
|
[[ $ip == *:::* ]] && break
|
|
local compressed=0
|
|
if [[ $ip == *::* ]]; then
|
|
[[ ${ip#*::} == *::* ]] && break
|
|
compressed=1
|
|
fi
|
|
|
|
# A lone leading/trailing ':' that is not part of a '::' is invalid.
|
|
[[ $ip == :* && $ip != ::* ]] && break
|
|
[[ $ip == *: && $ip != *:: ]] && break
|
|
|
|
# An embedded IPv4 is allowed only as the trailing 32 bits. Validate it,
|
|
# then replace it with two zero hextets so the rest is a clean hextet list.
|
|
if [[ $ip == *.* ]]; then
|
|
local v4="${ip##*:}"
|
|
[[ $v4 == *.*.*.* ]] || break
|
|
local -a oct=()
|
|
IFS='.' read -ra oct <<< "$v4"
|
|
[[ ${#oct[@]} -eq 4 ]] || break
|
|
local o v4ok=1
|
|
for o in "${oct[@]}"; do
|
|
if [[ ! $o =~ ^[0-9]{1,3}$ ]] || (( 10#$o > 255 )); then
|
|
v4ok=0
|
|
break
|
|
fi
|
|
done
|
|
(( v4ok )) || break
|
|
ip="${ip%"$v4"}0:0"
|
|
fi
|
|
|
|
# Count the hextets on each side of an optional '::'.
|
|
local left right seg part total=0 ok=1
|
|
if (( compressed )); then
|
|
left="${ip%%::*}"
|
|
right="${ip#*::}"
|
|
else
|
|
left="$ip"
|
|
right=""
|
|
fi
|
|
|
|
for seg in "$left" "$right"; do
|
|
[[ -z $seg ]] && continue
|
|
local -a parts=()
|
|
IFS=':' read -ra parts <<< "$seg"
|
|
for part in "${parts[@]}"; do
|
|
[[ $part =~ ^[0-9a-fA-F]{1,4}$ ]] || { ok=0; break; }
|
|
total=$(( total + 1 ))
|
|
done
|
|
(( ok )) || break
|
|
done
|
|
(( ok )) || break
|
|
|
|
# Without '::' there must be exactly 8 hextets; with it, fewer than 8
|
|
# (the '::' expands to at least one zero hextet).
|
|
if (( compressed )); then
|
|
(( total <= 7 )) || break
|
|
else
|
|
(( total == 8 )) || break
|
|
fi
|
|
|
|
valid=1
|
|
break
|
|
done
|
|
|
|
if (( valid )); then
|
|
[[ -t 1 ]] && disp I "The given IPv6 is valid."
|
|
return 0
|
|
fi
|
|
[[ -t 1 ]] && disp E "The given parameter is not a valid IPv6."
|
|
return 1
|
|
}
|
|
export -f isipv6
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# 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 <string>
|
|
urlencode()
|
|
{
|
|
# 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
|
|
for (( i = 0; i < length; i++ )); do
|
|
local c="${str:i:1}"
|
|
case "$c" in
|
|
[a-zA-Z0-9.~_-]) printf '%s' "$c" ;;
|
|
*) printf '%%%02X' "'$c" ;;
|
|
esac
|
|
done
|
|
}
|
|
export -f urlencode
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Fetch and display external IP information
|
|
# Usage: myextip [-i|--ip] [-s|--isp] [-l|--loc] [-c|--coord]
|
|
# If no option is provided, all information will be displayed.
|
|
# Options:
|
|
# -h, --help Display help screen
|
|
# -i, --ip Display only the external IP address
|
|
# -s, --isp Display only the ISP name
|
|
# -l, --loc Display only the location (city, region, country)
|
|
# -c, --coord Display only the coordinates (latitude, longitude)
|
|
# -a, --as Display only the Autonomous System (AS) information
|
|
# -R, --raw Display raw JSON response
|
|
myextip()
|
|
{
|
|
local show_ip=false show_isp=false show_loc=false
|
|
local show_coord=false show_as=false show_raw=false
|
|
local all=true
|
|
|
|
# Parse arguments
|
|
while [[ "$#" -gt 0 ]]; do
|
|
case "$1" in
|
|
-i|--ip)
|
|
show_ip=true
|
|
all=false
|
|
;;
|
|
-s|--isp)
|
|
show_isp=true
|
|
all=false
|
|
;;
|
|
-l|--loc)
|
|
show_loc=true
|
|
all=false
|
|
;;
|
|
-c|--coord)
|
|
show_coord=true
|
|
all=false
|
|
;;
|
|
-a|--as)
|
|
show_as=true
|
|
all=false
|
|
;;
|
|
-R|--raw)
|
|
all=false
|
|
show_raw=true
|
|
;;
|
|
-h|--help)
|
|
printf "Fetch and display external IP information.\n\n"
|
|
printf "Usage: myextip [-i|--ip] [-s|--isp] [-l|--loc] [-c|--coord] [-a|--as] [-R|--raw]\n\n"
|
|
printf "Options:\n"
|
|
printf "\t-h, --help\tDisplay this help screen\n"
|
|
printf "\t-i, --ip\tDisplay only the external IP address\n"
|
|
printf "\t-s, --isp\tDisplay only the ISP name\n"
|
|
printf "\t-l, --loc\tDisplay only the location (city, region, country)\n"
|
|
printf "\t-c, --coord\tDisplay only the coordinates (latitude, longitude)\n"
|
|
printf "\t-a, --as\tDisplay only the Autonomous System (AS) information\n"
|
|
printf "\t-R, --raw\tDisplay raw JSON response\n"
|
|
return 0
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
*)
|
|
disp E "Unknown option: $1, use \"myextip --help\" to display usage."
|
|
return 1
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
# 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 -t 10 "$api_url"); then
|
|
disp E "Failed to fetch external IP information from $api_url"
|
|
return 2
|
|
fi
|
|
|
|
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<n> <name>", 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
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# EOF
|