fcnatfw/bin/fcnatfw.in
2026-02-19 02:15:39 +05:00

259 lines
5.4 KiB
Bash

#!/usr/bin/env bash
#
# Copyright (C)2026 kimapr
#
# This file is part of fcnatfw.
#
# fcnatfw is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# fcnatfw is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
# Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.
#
FCNATFW_INSTALLED=""
## PRELUDE_PLACEHOLDER ##
PORT="$1"
PROTOCOL="$2"
STUNSERVER="$3"
if test -z "$STUNSERVER"; then
STUNSERVER="stun.kimapr.net"
fi
if test -z "$PROTOCOL"; then
PROTOCOL=tcp
fi
dep() {
for i; do
if ! command -v "$i" >/dev/null; then
printf 'fatal: required program '\''%s'\'' not installed\n' "$i"
exit 1
fi
done
}
if test -z "$FCNATFW_INSTALLED"; then
wrapper="$(dirname "$(realpath "$(command -v "$0")")")/fcnatwrap"
dep ncat ping stunclient "$wrapper"
else
dep ping
fi
if
{ ! { [ "$PROTOCOL" = tcp ] || [ "$PROTOCOL" = udp ]; }; } ||
{ ! { printf '%s\n' "$PORT" | grep -q '^[0-9][0-9:]*$'; }; } ||
test -z "$STUNSERVER";
then
printf '%s\n' "Usage: fcnatfw PORT[:EXTERNAL_PORT...] PROTOCOL STUNSERVER_IP[:STUNSERVER_PORT]"
exit 1;
fi
port_of() { printf '%s\n' "$1" | sed -E 's/.*:([0-9]*)/\1/'; }
ip_of() { printf '%s\n' "$1" | sed -E 's/(.*):[0-9]*/\1/'; }
randstream() { local char; while true; do char=$((RANDOM%36)); if [ $char -ge 10 ]; then char=$((65+char-10)); else char=$((48+char)); fi; printf "\\$(printf '%.3o' "$char")"; done; }
upnp_id="fcnatfw_$(randstream | head -c10)"
if printf '%s\n' "$STUNSERVER" | grep -q ':[0-9]*$'; then
STUNPORT="$(port_of "$STUNSERVER")"
STUNIP="$(ip_of "$STUNSERVER")"
else
STUNIP="$(printf '%s\n' "$STUNSERVER")"
STUNPORT="3478"
fi
upnp_poke() {
local pid
local loc
( bash -c 'echo $PPID'
( (cat <<EOF
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 2
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1
EOF
) | sed 's/$/\r/'; sleep 2) | ncat -u "$1" 1900 | grep '^LOCATION' -m1 | sed 's/^LOCATION: *//'
exec true ) | {
read pid
read loc || return 1;
printf '%s\n' "$loc";
kill -3 $pid;
}; }
upnpc_direct() {
local addr
addr="$1"
shift
upnpc -u "$(upnp_poke "$addr")" "$@"
}
declare -a UPNPS
declare -a UMAPS
NO_UPNP_IP=''
find_upnp() {
local count
local addr
local url
count=0
UPNPS=()
while true; do
addr="$(ping -n -c 1 -t $((count + 1)) -w 2 "$STUNIP" | grep -o '^From [^ ]*[^ :]' | sed 's/^From //')";
if test -z "$addr" || test "$addr" = "$NO_UPNP_IP"; then
break;
fi
url="$(upnp_poke "$addr")"
if test -z "$url"; then
NO_UPNP_IP="$addr"
printf 'Not an UPnP server: %s\n' "$addr" >&2
break;
fi
UPNPS[count]="$url"
count=$((count + 1))
printf 'Found UPnP server [%s]: %s\n' "$count" "$url" >&2
done
}
ext_ports() {
printf '%s:' "$PORT" | {
lp=0
while read -d ':' port; do
if test -z "$port"; then
break;
fi
lp="$port"
echo "$port"
done
yes "$lp"
}
}
printf 'Internal port: %s (%s)\nSTUN server IP: %s\nSTUN server port: %s\n' "$(ext_ports | head -n1)" "$PROTOCOL" "$STUNIP" "$STUNPORT" >&2
upnp_unforward_single() {
upnpc -u "$1" -d "$2" "$PROTOCOL" >/dev/null
printf 'DELETE UPnP mapping %s -> %s\n' "$3" "$4" >&2
}
upnp_unforward() {
while [ "${#UMAPS[@]}" -gt 0 ]; do
upnp_unforward_single ${UMAPS[-1]}
unset UMAPS[-1]
done
}
upnp_forward() {
if test -z "${UPNPS[0]}"; then
return
fi
local addr
local oldfaddr
local port
local faddr
{
read port
addr="$(upnpc -u "${UPNPS[0]}" -s | grep 'Local LAN' -m1 | sed 's/.* *: *//')"
UMAPS=()
for i in "${UPNPS[@]}"; do
read portex
faddr="$(upnpc -u "$i" -e "$upnp_id" -a "$addr" "$port" "$portex" "$PROTOCOL" | grep -m1 'external .* is redirected' | sed -E 's/^external ([^ ]*).*$/\1/')"
if test -z "$faddr"; then
return 1;
fi
if test -z "$1"; then
printf 'CREATE UPnP mapping %s -> %s:%s\n' "$faddr" "$addr" "$port" >&2
fi
oldfaddr="$addr:$port"
addr="$(ip_of "$faddr")"
port="$(port_of "$faddr")"
UMAPS+=("$i $port $faddr $oldfaddr")
done;
} < <(ext_ports)
}
if ! command -v upnpc > /dev/null; then
find_upnp() {
true
}
upnp_unforward() {
true
}
upnp_forward() {
printf 'Warning: upnpc is missing\n' >&2
}
fi
time_ms() {
echo $(("$(date +%s%N | head -c-7)"))
}
ms_to_s() {
printf '%s\n' "$1" | sed -E 's/(...)$/.\1/'
}
stunloop() {
local faddr=""
local nfaddr
local dt
local stunout
local begin_t="$(time_ms)"
local last_upnp="$(time_ms)"
while true; do
printf 'binding STUN... ' >&2
stunout="$("$wrapper" stunclient "$STUNIP" "$STUNPORT" --localport "$(ext_ports | head -n1)" --protocol "$PROTOCOL")"
nfaddr="$(printf '%s\n' "$stunout" |
grep 'Mapped address' | sed 's/^[^:]*: *//')"
if test -z "$nfaddr"; then
printf "Error! %s\n" "$stunout" >&2
else
printf "Ok (%s)\n" "$nfaddr" >&2
fi
if ! test "$faddr" = "$nfaddr"; then
printf '%s\n' "$nfaddr";
faddr="$nfaddr";
fi
dt="$((5000 - ($(time_ms) - begin_t) ))"
if [ $dt -ge 0 ]; then
sleep $(ms_to_s $dt)
fi
begin_t="$(time_ms)"
if [ $(($(time_ms) - last_upnp)) -ge 30000 ]; then
find_upnp
upnp_forward
last_upnp="$(time_ms)"
fi
done
}
trap upnp_unforward EXIT
find_upnp
upnp_forward
stunloop