Files
profile/profile.d/net.sh
T

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