#!/usr/bin/env bash
#
# tcpix tunnel installer
# ----------------------
# Reproduces, on a clean OS, exactly the deployment running on the production
# pair: a systemd-managed tcpix daemon, the persistent sysctl tuning, and a
# firewall unit that owns the port-forward rules.
#
# Real-IP preservation is the point of the design: the client DNATs inbound
# traffic into the tunnel WITHOUT masquerading, so the server sees the true
# client address. Return traffic finds its way back because tcpix marks the
# conntrack of anything arriving on its interface and policy-routes replies
# into the tunnel (table 100 / fwmark 0x64). The tunnel binary installs and
# re-asserts those rules itself every 60s, so the server never needs the port
# list -- the client's fw.sh is the single place ports are declared.
#
#   Usage: sudo bash install-tcpix.sh
#
set -uo pipefail

BASE_URL="${TCPIX_BASE_URL:-https://nickfury.nl/extras}"
INSTALL_DIR=/root/tcpix
ENV_FILE=/etc/tcpix.env
SYSCTL_FILE=/etc/sysctl.d/99-tcpix-tunnel.conf
BIN="$INSTALL_DIR/tcpix"

# --- Presentation -----------------------------------------------------------

if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then
    R=$'\033[0m'; B=$'\033[1m'; DIM=$'\033[2m'
    RED=$'\033[31m'; GRN=$'\033[32m'; YLW=$'\033[33m'
    BLU=$'\033[34m'; MAG=$'\033[35m'; CYN=$'\033[36m'
else
    R=""; B=""; DIM=""; RED=""; GRN=""; YLW=""; BLU=""; MAG=""; CYN=""
fi

info()  { printf '%s\n' "${CYN}::${R} $*"; }
ok()    { printf '%s\n' "${GRN} ok${R} $*"; }
warn()  { printf '%s\n' "${YLW}  !${R} $*"; }
die()   { printf '%s\n' "${RED}fail${R} $*" >&2; exit 1; }
step()  { printf '\n%s\n' "${B}${BLU}==>${R} ${B}$*${R}"; }

banner() {
    printf '%s\n' "${B}${MAG}"
    cat <<'ART'
  _              _
 | |_ ___ _ __  (_)_  __
 | __/ __| '_ \ | \ \/ /
 | || (__| |_) || |>  <
  \__\___| .__/_/ /_/\_\
         |_|  |__/
ART
    printf '%s\n' "${R}${DIM}         tunnel installer${R}"
}

# Prompt with a default. Answer lands in REPLY_VAL.
ask() { # ask <prompt> [default]
    local p="$1" d="${2:-}" a prompt
    if [ -n "$d" ]; then
        prompt="$(printf '%s' "${B}?${R} $p ${DIM}[$d]${R}: ")"
    else
        prompt="$(printf '%s' "${B}?${R} $p: ")"
    fi
    # A failed read means stdin hit EOF. Falling through would spin the
    # validation loops forever, so take the default or stop outright.
    if ! read -r -p "$prompt" a; then
        printf '\n'
        [ -n "$d" ] || die "Unexpected end of input (this installer needs an interactive terminal)."
        REPLY_VAL="$d"
        return 0
    fi
    REPLY_VAL="${a:-$d}"
}

confirm() { # confirm <prompt> <default y|n>
    local p="$1" d="${2:-y}" a hint="Y/n"
    [ "$d" = n ] && hint="y/N"
    while :; do
        if ! read -r -p "$(printf '%s' "${B}?${R} $p ${DIM}[$hint]${R}: ")" a; then
            printf '\n'; a="$d"
        fi
        a="${a:-$d}"
        case "${a,,}" in y|yes) return 0 ;; n|no) return 1 ;; esac
        warn "Please answer y or n."
    done
}

# --- Preflight --------------------------------------------------------------

banner
[ "$(id -u)" -eq 0 ] || die "Run as root (sudo bash $0)."
[ -d /run/systemd/system ] || die "systemd is required; this host is not running it."

step "Preflight"

case "$(uname -m)" in
    x86_64|amd64)   ARCH=amd64 ;;
    aarch64|arm64)  ARCH=arm64 ;;
    *)              die "Unsupported architecture: $(uname -m) (need x86_64 or aarch64)." ;;
esac
ok "Architecture: ${B}$ARCH${R}"

install_deps() {
    local missing=()
    command -v curl     >/dev/null 2>&1 || missing+=(curl)
    command -v iptables >/dev/null 2>&1 || missing+=(iptables)
    command -v ip       >/dev/null 2>&1 || missing+=(iproute2)
    command -v ping     >/dev/null 2>&1 || missing+=(iputils-ping)
    [ ${#missing[@]} -eq 0 ] && { ok "Dependencies present."; return; }

    info "Installing: ${missing[*]}"
    if command -v apt-get >/dev/null 2>&1; then
        DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1
        DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" >/dev/null 2>&1
    elif command -v dnf >/dev/null 2>&1; then
        dnf install -y -q "${missing[@]/iputils-ping/iputils}" >/dev/null 2>&1
    elif command -v yum >/dev/null 2>&1; then
        yum install -y -q "${missing[@]/iputils-ping/iputils}" >/dev/null 2>&1
    elif command -v apk >/dev/null 2>&1; then
        apk add --quiet "${missing[@]/iputils-ping/iputils}" >/dev/null 2>&1
    else
        warn "No known package manager; install manually: ${missing[*]}"
    fi

    for c in curl iptables ip ping; do
        command -v "$c" >/dev/null 2>&1 || die "Still missing '$c' after install attempt."
    done
    ok "Dependencies installed."
}
install_deps

WAN_IF="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')"
[ -n "$WAN_IF" ] || die "Could not detect the default (WAN) interface."
ok "WAN interface: ${B}$WAN_IF${R}"

# --- Questions --------------------------------------------------------------

step "Configuration"

while :; do
    ask "Is this host the tunnel ${B}server${R} or the ${B}client${R}? (s/c)" "s"
    case "${REPLY_VAL,,}" in
        s|server) MODE=server; break ;;
        c|client) MODE=client; break ;;
        *) warn "Answer 's' for server or 'c' for client." ;;
    esac
done
ok "Mode: ${B}$MODE${R}"

# Internal subnet. The wire format carries a bare 4-byte address, so the client
# hardcodes /24 -- the daemon refuses anything else.
while :; do
    ask "Internal tunnel subnet (must be a /24)" "192.168.100.0/24"
    SUBNET="$REPLY_VAL"
    if [[ "$SUBNET" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/24$ ]]; then
        o1=${BASH_REMATCH[1]}; o2=${BASH_REMATCH[2]}; o3=${BASH_REMATCH[3]}
        if (( o1<=255 && o2<=255 && o3<=255 )); then
            SUBNET="$o1.$o2.$o3.0/24"
            TUNNEL_SERVER_IP="$o1.$o2.$o3.1"
            break
        fi
    fi
    warn "Enter a /24 network, e.g. 192.168.100.0/24"
done
ok "Subnet ${B}$SUBNET${R} — server side of the tunnel will be ${B}$TUNNEL_SERVER_IP${R}"

# Token.
printf '%s\n' "${DIM}  The token authenticates the tunnel; it must be IDENTICAL on both ends.${R}"
if [ "$MODE" = server ]; then
    SUGGESTED="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
    ask "Shared token (ENTER accepts the generated one)" "$SUGGESTED"
else
    while :; do
        ask "Shared token (exactly as configured on the server)"
        [ -n "$REPLY_VAL" ] && break
        warn "The token cannot be empty."
    done
fi
TOKEN="$REPLY_VAL"
printf '%s\n' "${YLW}  ! This token MUST match on the server and every client, character for character.${R}"
ok "Token set (${#TOKEN} chars)."

# Port.
while :; do
    ask "Tunnel port" "27015"
    PORT="$REPLY_VAL"
    [[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT>=1 && PORT<=65535 )) && break
    warn "Enter a port between 1 and 65535."
done
ok "Port: ${B}$PORT${R}"

# Server address (client only).
if [ "$MODE" = client ]; then
    while :; do
        ask "Public IP (or hostname) of the tunnel server"
        SERVER_ADDR="$REPLY_VAL"
        [ -n "$SERVER_ADDR" ] && break
        warn "The server address cannot be empty."
    done
    ok "Server: ${B}$SERVER_ADDR:$PORT${R}"
fi

# --- Fetch the binary -------------------------------------------------------

step "Installing binary"

mkdir -p "$INSTALL_DIR"

fetch_binary() {
    local url="$BASE_URL/tcpix_$ARCH" tmp="$INSTALL_DIR/.tcpix.new" local_copy=""
    local here; here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

    # A copy sitting next to the installer (or in ./dist) wins over the network,
    # which makes offline and air-gapped installs work unchanged.
    for c in "$here/tcpix_$ARCH" "$here/dist/tcpix_$ARCH"; do
        [ -f "$c" ] && { local_copy="$c"; break; }
    done

    if [ -n "$local_copy" ]; then
        info "Using local binary $local_copy"
        cp -f "$local_copy" "$tmp" || die "Could not copy $local_copy"
    else
        info "Downloading $url"
        curl -fL --retry 3 --retry-delay 2 --connect-timeout 15 -o "$tmp" "$url" \
            || die "Download failed: $url"
    fi

    [ -s "$tmp" ] || die "Downloaded file is empty."
    if command -v file >/dev/null 2>&1; then
        file -b "$tmp" | grep -q ELF || die "Downloaded file is not an ELF binary (wrong URL?)."
    else
        head -c4 "$tmp" | grep -q ELF || die "Downloaded file is not an ELF binary (wrong URL?)."
    fi

    chmod +x "$tmp"
    mv -f "$tmp" "$BIN"

    # Run it once: with no arguments tcpix prints its usage line and exits 1,
    # which is the cheapest proof that the binary matches this architecture.
    # Captured rather than piped, so the expected exit 1 cannot trip pipefail.
    local probe; probe="$("$BIN" 2>&1 || true)"
    case "$probe" in
        *[Uu]sage*) : ;;
        *) die "Binary will not run on this host (architecture mismatch?): $probe" ;;
    esac
    ok "Installed $BIN"
}
fetch_binary

# --- Kernel tuning ----------------------------------------------------------

step "Kernel tuning"

modprobe tcp_bbr 2>/dev/null
echo tcp_bbr > /etc/modules-load.d/tcpix-bbr.conf

cat > "$SYSCTL_FILE" <<EOF
# tcpix tunnel -- applied at boot and by the unit's ExecStartPre.
#
# The tunnel carries a single long-haul TCP stream, so the socket buffer cap is
# the throughput ceiling: at ~70ms RTT the 4MB default limits one stream to
# roughly 480 Mbit/s. Raising it measured 2.7-4.6x faster.
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
net.core.rmem_default = 262144
net.core.wmem_default = 262144
net.core.optmem_max = 65536
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
net.ipv4.udp_rmem_min = 8192
net.ipv4.udp_wmem_min = 8192
net.core.netdev_max_backlog = 16384
net.core.somaxconn = 8192
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_notsent_lowat = 16384
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_fastopen = 3
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15

# Forwarding is what makes this box a tunnel endpoint at all.
net.ipv4.ip_forward = 1

# Loose reverse-path filtering. Traffic is deliberately asymmetric: requests
# arrive on the WAN and replies leave through the tunnel (and vice versa), so
# strict rp_filter would drop the very packets the real-IP design depends on.
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
net.ipv4.conf.$WAN_IF.rp_filter = 2

net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
EOF

sysctl -q -p "$SYSCTL_FILE" 2>/dev/null
ok "Wrote and applied $SYSCTL_FILE"

if [ "$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)" = bbr ]; then
    ok "Congestion control: bbr"
else
    warn "BBR not active (kernel lacks tcp_bbr); the tunnel still runs, just slower."
fi

# --- Environment file -------------------------------------------------------

step "Service configuration"

if [ "$MODE" = server ]; then
    cat > "$ENV_FILE" <<EOF
TCPIX_LISTEN=0.0.0.0:$PORT
TCPIX_SUBNET=$SUBNET
TCPIX_TOKEN=$TOKEN
EOF
else
    cat > "$ENV_FILE" <<EOF
TCPIX_SERVER=$SERVER_ADDR:$PORT
TCPIX_TOKEN=$TOKEN
EOF
fi
chmod 600 "$ENV_FILE"
ok "Wrote $ENV_FILE (mode 600)"

SYSCTL_BIN="$(command -v sysctl || echo /sbin/sysctl)"

# --- Firewall ---------------------------------------------------------------

# Both ends flush the tables before laying down their own rules, exactly as the
# production pair does. On a clean OS that is a no-op; warn if it is not.
# grep -c exits 1 when it counts zero, so swallow the status and keep the count.
existing_rules=$(iptables-save 2>/dev/null | grep -c '^-A' || true)
[ -n "$existing_rules" ] || existing_rules=0
if [ "$existing_rules" -gt 0 ]; then
    warn "This host already has $existing_rules iptables rule(s)."
    warn "The firewall unit flushes all tables before applying the tunnel rules."
    confirm "Continue and let the installer own the firewall?" y \
        || die "Aborted at the firewall step; nothing further was changed."
fi

write_server_fw() {
    cat > "$INSTALL_DIR/fw.sh" <<EOF
#!/usr/bin/env bash
# tcpix tunnel server -- static firewall rules.  Generated by install-tcpix.sh.
#
# Forwarded ports are NOT listed here, by design. tcpix stamps the conntrack of
# every connection arriving on its interface and policy-routes the replies back
# through the tunnel, so the port list in the CLIENT's fw.sh is the only place
# ports are ever declared. tcpix installs and re-asserts those rules itself.

TUN=tun_srv
WAN=\$(ip route show default | awk '/default/ {print \$5}' | head -n1)
PORT=$PORT
EOF
    cat >> "$INSTALL_DIR/fw.sh" <<'EOF'

iptables -F; iptables -X; iptables -Z
iptables -t nat -F; iptables -t nat -X; iptables -t nat -Z
iptables -t mangle -F; iptables -t mangle -X; iptables -t mangle -Z
iptables -t raw -F; iptables -t raw -X; iptables -t raw -Z
iptables -P INPUT ACCEPT; iptables -P FORWARD ACCEPT; iptables -P OUTPUT ACCEPT

ip6tables -F; ip6tables -X; ip6tables -Z 2>/dev/null
ip6tables -t nat -F; ip6tables -t nat -X; ip6tables -t nat -Z 2>/dev/null
ip6tables -t mangle -F; ip6tables -t mangle -X; ip6tables -t mangle -Z 2>/dev/null
ip6tables -P INPUT ACCEPT; ip6tables -P FORWARD ACCEPT; ip6tables -P OUTPUT ACCEPT 2>/dev/null

sysctl -q -w net.ipv4.ip_forward=1
sysctl -q -w net.ipv4.conf.all.rp_filter=2
sysctl -q -w "net.ipv4.conf.$TUN.rp_filter=2" 2>/dev/null
sysctl -q -w "net.ipv4.conf.$WAN.rp_filter=2" 2>/dev/null

# The tunnel's own listener.
iptables -A INPUT -p tcp --dport "$PORT" -j ACCEPT

# Prevent MTU/MSS mismatch drops over the tunnel.
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

# Path-MTU discovery must survive.
iptables -A INPUT   -p icmp --icmp-type destination-unreachable -j ACCEPT
iptables -A FORWARD -p icmp --icmp-type destination-unreachable -j ACCEPT

echo "[fw.sh] server rules applied (tunnel port $PORT; tcpix owns the mark/route rules)"
EOF
    chmod +x "$INSTALL_DIR/fw.sh"
}

write_client_fw() { # $1 = tcp forwards, $2 = udp forwards
    cat > "$INSTALL_DIR/fw.sh" <<EOF
#!/usr/bin/env bash
# tcpix tunnel client -- port-forward into the tunnel.  Generated by install-tcpix.sh.
#
# NO MASQUERADE on the forwarded traffic: the original source IP is kept so the
# server sees the real client. Return traffic comes back through the tunnel
# because the server marks the conntrack on arrival and policy-routes the
# replies -- tcpix installs those rules itself, port-agnostically, so no port
# list is needed on the server side.

TUN=tun_cli
WAN=\$(ip route show default | awk '/default/ {print \$5}' | head -n1)
TUNNEL_SERVER_IP="$TUNNEL_SERVER_IP"

# These two lists are the ONLY place forwarded ports are declared.
# Each entry is "src" (port preserved) or "src,dst". Ranges use a dash.
TCP_FORWARDS="$1"
UDP_FORWARDS="$2"
EOF
    cat >> "$INSTALL_DIR/fw.sh" <<'EOF'

sysctl -q -w net.ipv4.ip_forward=1
# loose rp_filter: requests arrive on the WAN, replies come back on the tunnel
sysctl -q -w net.ipv4.conf.all.rp_filter=2
sysctl -q -w "net.ipv4.conf.$TUN.rp_filter=2" 2>/dev/null
sysctl -q -w "net.ipv4.conf.$WAN.rp_filter=2" 2>/dev/null

iptables -F; iptables -X; iptables -Z
iptables -t nat -F; iptables -t nat -X; iptables -t nat -Z
iptables -t mangle -F; iptables -t mangle -X; iptables -t mangle -Z
iptables -t raw -F; iptables -t raw -X; iptables -t raw -Z
iptables -P INPUT ACCEPT; iptables -P FORWARD ACCEPT; iptables -P OUTPUT ACCEPT

ip6tables -F; ip6tables -X; ip6tables -Z 2>/dev/null
ip6tables -t nat -F; ip6tables -t nat -X; ip6tables -t nat -Z 2>/dev/null
ip6tables -t mangle -F; ip6tables -t mangle -X; ip6tables -t mangle -Z 2>/dev/null
ip6tables -P INPUT ACCEPT; ip6tables -P FORWARD ACCEPT; ip6tables -P OUTPUT ACCEPT 2>/dev/null

# Prevent MTU/MSS mismatch drops over the tunnel
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

apply_forwards() { # $1 = proto, $2 = space-separated list
    local proto="$1" entry src dst
    for entry in $2; do
        [ -z "$entry" ] && continue
        src="${entry%%,*}"
        dst="${entry#*,}"
        [ "$dst" = "$entry" ] && dst="$src"

        # iptables spells a match range "a:b" but a DNAT target range "a-b".
        local match_src="${src/-/:}" match_dst="${dst/-/:}"

        # DNAT only what arrives from the internet on the WAN, so tunnel traffic
        # is untouched. The source IP is left alone, which is what makes the
        # server see the real client address. When the destination port is the
        # same, --to-destination carries no port so the original one is kept.
        if [ "$src" = "$dst" ]; then
            iptables -t nat -A PREROUTING -i "$WAN" -p "$proto" --dport "$match_src" \
                -j DNAT --to-destination "$TUNNEL_SERVER_IP"
        else
            iptables -t nat -A PREROUTING -i "$WAN" -p "$proto" --dport "$match_src" \
                -j DNAT --to-destination "$TUNNEL_SERVER_IP:$dst"
        fi

        # FORWARD sees the packet after nat/PREROUTING, so it matches the
        # rewritten (destination) port, not the public one.
        iptables -A FORWARD -i "$WAN" -o "$TUN" -d "$TUNNEL_SERVER_IP" \
            -p "$proto" --dport "$match_dst" -j ACCEPT

        echo "[fw.sh] $proto $src -> $TUNNEL_SERVER_IP:$dst"
    done
}

apply_forwards tcp "$TCP_FORWARDS"
apply_forwards udp "$UDP_FORWARDS"

# Allow forwarded return traffic & ICMP Path MTU Discovery
iptables -A FORWARD -i "$TUN" -o "$WAN" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A FORWARD -p icmp --icmp-type destination-unreachable -j ACCEPT
iptables -A INPUT   -p icmp --icmp-type destination-unreachable -j ACCEPT

echo "[fw.sh] port-forwards applied to $TUNNEL_SERVER_IP (real client IP preserved)"
EOF
    chmod +x "$INSTALL_DIR/fw.sh"
}

cat > /etc/systemd/system/firewall.service <<EOF
[Unit]
Description=tcpix firewall rules ($INSTALL_DIR/fw.sh)
After=network-online.target
Wants=network-online.target
# The script flushes every table, so it must settle before the tunnel starts.
Before=tcpix-$MODE.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash $INSTALL_DIR/fw.sh

[Install]
WantedBy=multi-user.target
EOF

if [ "$MODE" = server ]; then
    write_server_fw
    cat > /etc/systemd/system/tcpix-server.service <<EOF
[Unit]
Description=tcpix tunnel (server)
After=network-online.target firewall.service
Wants=network-online.target
# Ordering only (not a hard dependency): the tunnel re-asserts its own rules
# every 60s, so it recovers even if the firewall script runs late or by hand.

[Service]
Type=simple
WorkingDirectory=$INSTALL_DIR
EnvironmentFile=$ENV_FILE
ExecStartPre=$SYSCTL_BIN -q -p $SYSCTL_FILE
ExecStart=$BIN -mode server -addr \${TCPIX_LISTEN} -token \${TCPIX_TOKEN} -subnet \${TCPIX_SUBNET}
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
    UNIT=tcpix-server.service
else
    write_client_fw "" ""
    cat > /etc/systemd/system/tcpix-client.service <<EOF
[Unit]
Description=tcpix tunnel (client)
After=network-online.target firewall.service
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=$INSTALL_DIR
EnvironmentFile=$ENV_FILE
ExecStartPre=$SYSCTL_BIN -q -p $SYSCTL_FILE
ExecStart=$BIN -mode client -addr \${TCPIX_SERVER} -token \${TCPIX_TOKEN}
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
    UNIT=tcpix-client.service
fi
ok "Wrote $INSTALL_DIR/fw.sh, firewall.service and $UNIT"

# --- Start ------------------------------------------------------------------

step "Starting services"

systemctl daemon-reload
systemctl enable --now firewall.service >/dev/null 2>&1 \
    || warn "firewall.service reported a problem; see: journalctl -u firewall.service"
ok "firewall.service enabled and applied"

systemctl enable "$UNIT" >/dev/null 2>&1
systemctl restart "$UNIT"
sleep 2
if systemctl is-active --quiet "$UNIT"; then
    ok "$UNIT is running"
else
    printf '%s\n' "${RED}--- last log lines ---${R}"
    journalctl -u "$UNIT" -n 20 --no-pager
    die "$UNIT failed to start."
fi

# --- Server: done -----------------------------------------------------------

if [ "$MODE" = server ]; then
    step "Server ready"
    printf '  %-22s %s\n' "Listening on:"   "${B}0.0.0.0:$PORT${R}"
    printf '  %-22s %s\n' "Tunnel subnet:"  "${B}$SUBNET${R}"
    printf '  %-22s %s\n' "Server tunnel IP:" "${B}$TUNNEL_SERVER_IP${R}"
    printf '  %-22s %s\n' "Token:"          "${B}$TOKEN${R}"
    printf '\n%s\n' "${DIM}Give the clients this server's public IP, the port, and the token above.${R}"
    printf '%s\n'   "${DIM}Ports are NOT configured here -- the client declares them; the server${R}"
    printf '%s\n'   "${DIM}routes replies back automatically and services see the real client IP.${R}"
    printf '\n%s\n' "${DIM}  status:  systemctl status $UNIT${R}"
    printf '%s\n'   "${DIM}  logs:    journalctl -u $UNIT -f${R}"
    printf '\n%s\n' "${GRN}${B}Done.${R}"
    exit 0
fi

# --- Client: connectivity ---------------------------------------------------

step "Verifying tunnel connectivity"

info "Pinging $TUNNEL_SERVER_IP through the tunnel (up to 45s)..."
CONNECTED=0
for i in $(seq 1 45); do
    if ping -c1 -W1 "$TUNNEL_SERVER_IP" >/dev/null 2>&1; then
        CONNECTED=1
        break
    fi
    printf '\r   %s waiting... %ss' "${DIM}" "$i"
    sleep 1
done
printf '\r%*s\r' 40 ''

if [ "$CONNECTED" -eq 1 ]; then
    MY_IP="$(ip -4 -o addr show dev tun_cli 2>/dev/null | awk '{print $4}' | cut -d/ -f1)"
    ok "Tunnel is up. This client is ${B}${MY_IP:-?}${R}, server is ${B}$TUNNEL_SERVER_IP${R}"
else
    warn "No reply from $TUNNEL_SERVER_IP."
    printf '%s\n' "${DIM}  Common causes: token mismatch, the server's port $PORT is not reachable,${R}"
    printf '%s\n' "${DIM}  or the server was installed with a different subnet.${R}"
    printf '%s\n' "${RED}--- last log lines ---${R}"
    journalctl -u "$UNIT" -n 15 --no-pager
    confirm "Configure port forwards anyway?" n \
        || { printf '\n%s\n' "${YLW}Stopped before port forwarding. Fix connectivity, then re-run this installer.${R}"; exit 1; }
fi

# --- Client: port forwards --------------------------------------------------

step "Port forwarding"

valid_spec() { # a port, or a dash range
    local s="$1"
    if [[ "$s" =~ ^[0-9]+$ ]]; then
        (( s>=1 && s<=65535 )); return
    fi
    if [[ "$s" =~ ^([0-9]+)-([0-9]+)$ ]]; then
        local a=${BASH_REMATCH[1]} b=${BASH_REMATCH[2]}
        (( a>=1 && a<=65535 && b>=1 && b<=65535 && a<b )); return
    fi
    return 1
}

span() { # number of ports covered by a spec
    local s="$1"
    if [[ "$s" =~ ^([0-9]+)-([0-9]+)$ ]]; then
        echo $(( BASH_REMATCH[2] - BASH_REMATCH[1] + 1 ))
    else
        echo 1
    fi
}

collect() { # $1 = TCP|UDP ; result in FORWARDS
    local proto="$1" line entry src dst out ok_all
    printf '\n%s\n' "${B}$proto port forwards${R}"
    printf '%s\n' "${DIM}  Space-separated entries. Each is  source  or  source,destination.${R}"
    printf '%s\n' "${DIM}  Ranges use a dash:  20000-20100  or  20000-20100,30000-30100${R}"
    printf '%s\n' "${DIM}  Example:  80  443,8443  20000-20100${R}"
    printf '%s\n' "${DIM}  Leave empty for no $proto forwards.${R}"
    while :; do
        ask "$proto ports" ""
        line="$REPLY_VAL"
        out=""; ok_all=1
        # Commas separate the pair, so entries are split on whitespace only.
        for entry in $line; do
            src="${entry%%,*}"
            dst="${entry#*,}"
            [ "$dst" = "$entry" ] && dst="$src"
            if [ "${entry: -1}" = "," ] || [ "${entry:0:1}" = "," ]; then
                warn "Invalid entry: '$entry' -- no space around the comma (write 80,8080)."
                ok_all=0; break
            fi
            if ! valid_spec "$src" || ! valid_spec "$dst"; then
                warn "Invalid entry: '$entry' (want  port  or  port,port  or  a-b,c-d)"
                ok_all=0; break
            fi
            if [ "$(span "$src")" != "$(span "$dst")" ]; then
                warn "Invalid entry: '$entry' (source and destination ranges must be the same size)"
                ok_all=0; break
            fi
            # A DNAT port RANGE is not a 1:1 shift: netfilter picks a free port
            # inside the target range, so a moved range does not line up
            # port-for-port. Equal ranges are fine (the original port is kept).
            if [ "$src" != "$dst" ] && [ "$(span "$src")" != 1 ]; then
                warn "'$entry': a shifted port RANGE is not mapped 1:1 -- netfilter"
                warn "  picks a free port in $dst. Use equal ranges to keep ports intact."
            fi
            out="$out $src,$dst"
        done
        [ "$ok_all" -eq 1 ] && { FORWARDS="${out# }"; return; }
    done
}

collect TCP; TCP_FWD="$FORWARDS"
collect UDP; UDP_FWD="$FORWARDS"

if [ -z "$TCP_FWD" ] && [ -z "$UDP_FWD" ]; then
    warn "No forwards configured. The tunnel is up but nothing is being forwarded."
    warn "Re-run this installer, or edit TCP_FORWARDS / UDP_FORWARDS in $INSTALL_DIR/fw.sh."
fi

write_client_fw "$TCP_FWD" "$UDP_FWD"
systemctl restart firewall.service \
    || die "firewall.service failed; see: journalctl -u firewall.service"
ok "Firewall rules applied"

# --- Client: done -----------------------------------------------------------

step "Client ready"
printf '  %-22s %s\n' "Tunnel server:"    "${B}$SERVER_ADDR:$PORT${R}"
printf '  %-22s %s\n' "Server tunnel IP:" "${B}$TUNNEL_SERVER_IP${R}"
printf '  %-22s %s\n' "This client:"      "${B}${MY_IP:-(pending)}${R}"
printf '  %-22s %s\n' "WAN interface:"    "${B}$WAN_IF${R}"

show_map() {
    local proto="$1" list="$2" entry src dst
    for entry in $list; do
        src="${entry%%,*}"; dst="${entry#*,}"
        printf '  %-22s %s\n' "" \
            "${GRN}$proto${R}  $WAN_IF:${B}$src${R} ${DIM}->${R} $TUNNEL_SERVER_IP:${B}$dst${R}"
    done
}
if [ -n "$TCP_FWD$UDP_FWD" ]; then
    printf '\n  %s\n' "${B}Forwards${R} ${DIM}(source IP preserved end to end)${R}"
    show_map TCP "$TCP_FWD"
    show_map UDP "$UDP_FWD"
fi

printf '\n%s\n' "${DIM}  status:     systemctl status $UNIT${R}"
printf '%s\n'   "${DIM}  logs:       journalctl -u $UNIT -f${R}"
printf '%s\n'   "${DIM}  edit ports: $INSTALL_DIR/fw.sh  then  systemctl restart firewall${R}"
printf '%s\n'   "${DIM}  nat rules:  iptables -t nat -S PREROUTING${R}"
printf '\n%s\n' "${GRN}${B}Done.${R}"
