#!/usr/bin/env bash
# ==============================================================================
#  tingy — клиент self-hosted туннелей pongy (аналог pinggy CLI)
#  Сервер по умолчанию: t.proxemoxedududu.ru  (SSH-порт 443)
#
#  Главная команда — полноценный внешний IP ЭТОЙ машины одной командой:
#    bash tingy.sh ip --token ВАШ_ТОКЕН   # HTTP-прокси: http://домен:ПОРТ
#                                          выходной IP = IP этой машины
#                                          (Colab/ноутбука), не сервера
#
#  Остальное:
#    bash tingy.sh socks --token ВАШ_ТОКЕН         # SOCKS5-прокси наружу
#    bash tingy.sh forward 8888 --token ВАШ_ТОКЕН # проброс порта наружу
#    bash tingy.sh web 8000 --sub myapp -t ТОКЕН  # http://myapp.домен
#    bash tingy.sh status --token ВАШ_ТОКЕН       # список туннелей
#
#  Скрипт самодостаточен: нужен только python3 (ssh НЕ обязателен — без
#  openssh туннель поднимет встроенный python-клиент; при первом таком
#  запуске pip сам поставит asyncssh в ~/.tingy/pylib).
#  Совместимость: старое имя pong.sh и переменные PONGY_* тоже работают.
#
#  Порты: НАРУЖНЫЙ порт в адресе выдаёт сервер (случайный свободный,
#  фиксируется --remote). ЛОКАЛЬНЫЙ порт движка (ip: 8899, socks: 1080)
#  наружу не смотрит; если занят — берётся случайный свободный,
#  явный выбор: «tingy.sh ip random».
#
#  Openssh-клиент НЕ обязателен: если бинарника ssh нет, туннель поднимает
#  встроенный python-клиент (asyncssh) — тот же протокол, что у ssh -R.
#  Версия: 1.3.0
# ==============================================================================
set -uo pipefail

PROG="$(basename "$0")"; PROG="${PROG%.sh}"; [ "$PROG" = "bash" ] && PROG="tingy"

TINGY_HOST="${TINGY_HOST:-${PONGY_HOST:-t.proxemoxedududu.ru}}"
TINGY_PORT="${TINGY_PORT:-${PONGY_PORT:-443}}"
TINGY_TOKEN="${TINGY_TOKEN:-${PONGY_TOKEN:-}}"
TINGY_HOME="${TINGY_HOME:-${PONGY_HOME:-$HOME/.tingy}}"
TINGY_BG_RESULT="${TINGY_BG_RESULT:-${PONGY_BG_RESULT:-}}"

# есть ли openssh-клиент; если нет — туннель поднимет python-клиент
SSH_BIN="$(command -v ssh 2>/dev/null || true)"

SSH_OPTS=(
  -o StrictHostKeyChecking=no
  -o UserKnownHostsFile=/dev/null
  -o ServerAliveInterval=30
  -o ServerAliveCountMax=4
  -o ExitOnForwardFailure=yes
  -o ConnectTimeout=15
  # ВАЖНО: не ставьте LogLevel=ERROR — OpenSSH печатает «Allocated port N»
  # именно на уровне INFO (logit), с ERROR строка подавляется и мы не узнаем порт
  -o LogLevel=INFO
)

C_G='\033[32m'; C_C='\033[36m'; C_R='\033[31m'; C_B='\033[1m'; C_0='\033[0m'

say()  { printf "${C_C}•${C_0} %s\n" "$*"; }
ok()   { printf "${C_G}✓${C_0} %s\n" "$*"; }
die()  { printf "${C_R}✗ %s${C_0}\n" "$*" >&2; exit 1; }

banner() { # $1 - заголовок, остальное - строки адресов
  local title="$1"; shift
  printf "\n${C_B}┌──────────────────────────────────────────────────┐${C_0}\n"
  printf "${C_B}│${C_0} %-48s ${C_B}│${C_0}\n" "$title"
  printf "${C_B}├──────────────────────────────────────────────────┤${C_0}\n"
  local l
  for l in "$@"; do printf "${C_B}│${C_0} ${C_G}%-48s${C_0} ${C_B}│${C_0}\n" "$l"; done
  printf "${C_B}└──────────────────────────────────────────────────┘${C_0}\n\n"
}

usage() {
  cat <<'U'
tingy — клиент self-hosted туннелей pongy (аналог pinggy)
Сервер по умолчанию: t.proxemoxedududu.ru (SSH-порт 443)
Требования: только python3. Установленный ssh НЕ обязателен — без него
туннель поднимет встроенный python-клиент (pip поставит asyncssh в ~/.tingy/pylib)

Использование:  tingy.sh <команда> [параметры]

Команды:
  ip [ПОРТ|random]     HTTP-прокси наружу → http://домен:ПОРТ
                        ВЫХОДНОЙ IP = IP ЭТОЙ МАШИНЫ (Colab/ноутбука):
                        прокси крутится локально, сервер только пересылает TCP.
                        Встроенный движок без pip. ЛОКАЛЬНЫЙ порт: 8899,
                        если занят — случайный свободный; «random» — всегда
                        случайный. НАРУЖНЫЙ порт выдаёт сервер (случайный
                        свободный, фиксируется --remote).
  socks [ПОРТ|random]  SOCKS5-прокси наружу → socks5://домен:ПОРТ (локальный
                        порт 1080, «random» — случайный) (алиас: proxy)
  forward ЛОК_ПОРТ     пробросить локальный TCP-порт наружу
  web ЛОК_ПОРТ         HTTP-туннель с поддоменом (случайным или --sub ИМЯ)
                        (алиас: http)
  status               список активных туннелей на сервере
  stop                 остановить фоновый (--daemon) туннель

Параметры:
  -t, --token ТОКЕН    токен доступа (или переменная TINGY_TOKEN)
  -H, --host ДОМЕН     сервер (по умолчанию вшит в скрипт)
  -p, --port ПОРТ      SSH-порт сервера
  -r, --remote ПОРТ    фиксированный НАРУЖНЫЙ порт на сервере
                        (по умолчанию 0 = сервер выдаёт случайный свободный)
      --sub ИМЯ        поддомен: http://ИМЯ.домен (для команды web)
      --engine ДВИЖОК  ip: builtin (по умолчанию) или proxy.py
      --upstream URL   ip: выход через купленный прокси
                        (user:pass@host:port — сайты видят его IP, а не
                        дата-центр терминала; только движок builtin)
  -d, --daemon         работать в фоне (адрес пишется в ~/.tingy/last_address)
  -h, --help           эта справка

Примеры:
  bash tingy.sh ip --token ВАШ_ТОКЕН
  bash tingy.sh ip random --token ВАШ_ТОКЕН    # локальный порт случайный
  bash tingy.sh ip 8899 --remote 31500 -t ВАШ_ТОКЕН -d
  bash tingy.sh socks -t ВАШ_ТОКЕН
  bash tingy.sh web 8000 --sub myapp -t ВАШ_ТОКЕН
  bash tingy.sh ip -t ТОКЕН --upstream user:pass@proxy.example.com:8080

Google Colab (вставить в ячейку):
  !curl -fsSL http://t.proxemoxedududu.ru/tingy.sh -o tingy.sh
  !bash tingy.sh ip --token ВАШ_ТОКЕН
U
}

# ----------------------------------------------------------------------------
# разбор аргументов
# ----------------------------------------------------------------------------
CMD=""; OPT_LOCAL=""; OPT_REMOTE="0"; OPT_SUB=""; OPT_ENGINE=""; OPT_DAEMON=0
OPT_UPSTREAM="${TINGY_UPSTREAM:-}"
while [ $# -gt 0 ]; do
  case "$1" in
    ip)            CMD="ip" ;;
    socks|proxy)   CMD="socks" ;;
    forward)       CMD="forward" ;;
    web|http)      CMD="web" ;;
    status)        CMD="status" ;;
    stop)          CMD="stop" ;;
    -t|--token)    TINGY_TOKEN="${2:-}"; shift ;;
    -H|--host)     TINGY_HOST="${2:-}"; shift ;;
    -p|--port)     TINGY_PORT="${2:-}"; shift ;;
    -r|--remote)   OPT_REMOTE="${2:-0}"; shift ;;
      --sub)       OPT_SUB="${2:-}"; shift ;;
      --engine)    OPT_ENGINE="${2:-}"; shift ;;
      --upstream)  OPT_UPSTREAM="${2:-}"; shift ;;
    -d|--daemon)  OPT_DAEMON=1 ;;
    -h|--help)     usage; exit 0 ;;
    -*)            die "неизвестный параметр: $1 (см. --help)" ;;
    *)             OPT_LOCAL="$1" ;;
  esac
  shift
done
[ -n "$CMD" ] || { usage; exit 1; }

require_token() {
  [ -n "$TINGY_TOKEN" ] || die "укажите токен: --token ТОКЕН (или переменная TINGY_TOKEN)"
}
need() { command -v "$1" >/dev/null 2>&1 || die "не найден «$1» — установите его"; }

# чем поднимать туннель: openssh или встроенным python-клиентом
need_tunnel_client() {
  [ -n "$SSH_BIN" ] && return 0
  need python3
  ensure_asyncssh || die "нет openssh-клиента, и не удалось поставить python-клиент:
  установите openssh-client (apt install openssh-client) или pip (apt install python3-pip —
  тогда tingy сам поставит asyncssh: pip install --target ~/.tingy/pylib asyncssh)"
}

# ждёт строку "Allocated port N" в логе ssh; печатает N в stdout
get_alloc_port() { # $1 = файл лога, $2 = pid ssh
  local i p
  for i in $(seq 1 150); do
    p=$(grep -aoE 'Allocated port [0-9]+' "$1" 2>/dev/null | head -1 \
        | grep -aoE '[0-9]+$' || true)
    if [ -n "$p" ]; then printf '%s' "$p"; return 0; fi
    kill -0 "$2" 2>/dev/null || return 1
    sleep 0.2
  done
  return 1
}

# убеждаемся, что ssh не умер сразу (порт занят / сервер отклонил forward)
ensure_ssh_alive() { # $1 = pid
  sleep 0.7
  kill -0 "$1" 2>/dev/null
}

wait_tcp() { # $1 хост, $2 порт
  local i
  for i in $(seq 1 50); do
    (exec 3<>"/dev/tcp/$1/$2") 2>/dev/null && { exec 3>&-; return 0; }
    sleep 0.1
  done
  return 1
}

port_free() { # $1 порт — проверяет, свободен ли 127.0.0.1:$1
  ! (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
}

# свободный случайный локальный порт (выбирает ядро ОС — атомарно);
# запасной вариант для движков, не умеющих --port 0 (например, proxy.py)
pick_free_port() {
  python3 - 2>/dev/null <<'PY' || true
import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
}

# ждёт строку "READY host:port" от локального движка; печатает порт.
# Движки с --port 0 сами слушают на порте, выбранном ядром ОС, —
# это атомарно: порт гарантированно свободен в момент захвата
get_ready_port() { # $1 = файл лога, $2 = pid движка
  local i p
  for i in $(seq 1 100); do
    p=$(grep -aoE 'READY [^ ]+:[0-9]+' "$1" 2>/dev/null | head -1 \
        | grep -aoE '[0-9]+$' || true)
    if [ -n "$p" ]; then printf '%s' "$p"; return 0; fi
    kill -0 "$2" 2>/dev/null || return 1
    sleep 0.1
  done
  return 1
}

# локальный порт для ip/socks: $1 = порт по умолчанию (8899 / 1080).
# OPT_LOCAL: пусто → дефолт, а если занят → случайный свободный;
# random|auto|0 → всегда случайный; число → фиксированный.
# Результат: LOCAL_MODE (fixed|random) и LOCAL_PORT (пусто = случайный)
resolve_local_port() {
  local def="$1"
  case "${OPT_LOCAL:-}" in
    random|auto|any|0)
      LOCAL_MODE="random"; LOCAL_PORT="" ;;
    "")
      if port_free "$def"; then
        LOCAL_MODE="fixed"; LOCAL_PORT="$def"
      else
        say "порт 127.0.0.1:$def занят — возьму случайный свободный"
        LOCAL_MODE="random"; LOCAL_PORT=""
      fi ;;
    *[!0-9]*)
      die "ЛОК_ПОРТ должен быть числом или «random» (задано: $OPT_LOCAL)" ;;
    *)
      LOCAL_MODE="fixed"; LOCAL_PORT="$OPT_LOCAL" ;;
  esac
}

fetch_ip() { # $1 = адрес прокси; печатает внешний IP (или ничего)
  command -v curl >/dev/null 2>&1 || return 1
  curl -sx "$1" -m 12 https://api.ipify.org 2>/dev/null | tr -d '\r\n' || true
  return 0
}

# Репутация внешнего IP: пометки «hosting/proxy» в базах антифрода —
# именно из-за них сайты пишут «смените прокси» (ip-api.com, бесплатный).
ip_quality() { # $1 = адрес прокси, $2 = IP (опц.); печатает вердикт или пусто
  command -v curl >/dev/null 2>&1 || return 0
  command -v python3 >/dev/null 2>&1 || return 0
  local out
  out=$(curl -sx "$1" -m 10 \
    "http://ip-api.com/json/${2:-}?fields=status,proxy,hosting,isp" \
    2>/dev/null | tr -d '\r\n') || return 0
  [ -n "$out" ] || return 0
  printf '%s' "$out" | python3 -c '
import json, sys
try:
    d = json.load(sys.stdin)
except Exception:
    sys.exit(0)
if d.get("status") != "success":
    sys.exit(0)
tags = [k for k in ("proxy", "hosting") if d.get(k)]
isp = (d.get("isp") or "")[:30]
if tags:
    print("IP-репутация: помечен как %s (%s)" % ("+".join(tags), isp))
else:
    print("IP-репутация: чистый (%s)" % isp)
' 2>/dev/null || true
  return 0
}

# ----------------------------------------------------------------------------
# встроенный HTTP-прокси (чистый python3: CONNECT + обычные запросы)
# ----------------------------------------------------------------------------
write_http_proxy() { # $1 = путь к файлу
  cat > "$1" <<'HTTPEOF'
#!/usr/bin/env python3
"""Минимальный HTTP-прокси (CONNECT + обычные запросы). Зависимостей нет.

Выходной IP = IP машины, на которой запущен этот прокси."""
import argparse, asyncio, logging, sys
from urllib.parse import urlsplit

log = logging.getLogger("http-proxy")


async def pump(src, dst):
    try:
        while True:
            data = await src.read(65536)
            if not data:
                break
            dst.write(data)
            await dst.drain()
    except Exception:
        pass
    finally:
        try:
            dst.close()
        except Exception:
            pass


async def reply(writer, code, reason):
    try:
        writer.write(("HTTP/1.1 %d %s\r\nContent-Length: 0\r\n"
                      "Connection: close\r\n\r\n" % (code, reason))
                     .encode("ascii"))
        await writer.drain()
    except Exception:
        pass


def _hostport(s, default_port):
    """'host', 'host:port', '[v6]' или '[v6]:port' → (host, port)."""
    s = s.strip()
    if s.startswith("["):
        host, _, rest = s[1:].partition("]")
        if rest.startswith(":") and rest[1:].isdigit():
            return host, int(rest[1:])
        return host, default_port
    host, _, port = s.rpartition(":")
    if host and port.isdigit():
        return host, int(port)
    return s, default_port


def _parse_upstream(s):
    """'host:port' или 'user:pass@host:port' → (host, port, b64auth|None).

    Выход через купленный прокси: терминал остаётся бесплатным транспортом,
    а сайты видят чистый IP апстрима (а не дата-центр Colab/Azure).
    """
    import base64
    auth = None
    if "@" in s:
        creds, _, hostpart = s.rpartition("@")
        if creds:
            auth = base64.b64encode(creds.encode("utf-8")).decode("ascii")
        s = hostpart
    host, port = _hostport(s, 8080)
    return host, port, auth


UP = None      # (host, port, auth|None) — апстрим-прокси для выхода


async def _upstream_connect(host, port):
    """CONNECT host:port через апстрим-прокси (готовый туннель до сайта)."""
    ur, uw = await asyncio.wait_for(
        asyncio.open_connection(UP[0], UP[1]), 25)
    req = "CONNECT %s:%d HTTP/1.1\r\nHost: %s:%d\r\n" % (host, port, host, port)
    if UP[2]:
        req += "Proxy-Authorization: Basic %s\r\n" % UP[2]
    req += "\r\n"
    uw.write(req.encode("latin-1"))
    await uw.drain()
    status = await asyncio.wait_for(ur.readline(), 25)
    if b" 200 " not in status:
        raise ConnectionError("upstream: " + status.decode("latin-1", "?").strip())
    while True:                       # добить заголовки ответа апстрима
        line = await asyncio.wait_for(ur.readline(), 25)
        if line in (b"\r\n", b"\n", b""):
            break
    return ur, uw


async def handle(reader, writer):
    try:
        buf = b""
        while b"\r\n\r\n" not in buf:
            chunk = await asyncio.wait_for(reader.read(65536), 30)
            if not chunk:
                return
            buf += chunk
            if len(buf) > 65536:
                await reply(writer, 431, "Request Header Too Large")
                return
        head, _, rest = buf.partition(b"\r\n\r\n")
        lines = head.split(b"\r\n")
        try:
            method, target, _ver = lines[0].decode("latin-1").split(" ", 2)
        except ValueError:
            await reply(writer, 400, "Bad Request")
            return
        method = method.upper()

        if method == "CONNECT":                       # https через прокси
            host, port = _hostport(target, 443)
            if not host:
                await reply(writer, 400, "Bad Request")
                return
            try:
                if UP:            # цепочка: выходим через апстрим-прокси
                    ur, uw = await _upstream_connect(host, port)
                else:
                    ur, uw = await asyncio.wait_for(
                        asyncio.open_connection(host, port), 25)
            except Exception:
                await reply(writer, 502, "Bad Gateway")
                return
            writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
            await writer.drain()
            log.info("CONNECT %s:%d%s", host, port,
                     " (upstream %s:%d)" % (UP[0], UP[1]) if UP else "")
            if rest:
                uw.write(rest)
            await asyncio.gather(pump(reader, uw), pump(ur, writer))
            return

        # обычный HTTP-запрос через прокси: GET http://host:port/path
        host = None
        port = 80
        if "://" in target:
            u = urlsplit(target)
            host = u.hostname
            port = u.port or (443 if u.scheme == "https" else 80)
            path = (u.path or "/") + (("?" + u.query) if u.query else "")
        else:
            for line in lines[1:]:
                if line[:5].lower() == b"host:":
                    host, port = _hostport(line[5:].decode("latin-1"), 80)
                    break
            path = target
        if not host:
            await reply(writer, 400, "Bad Request")
            return
        if UP:            # цепочка: запрос уходит в апстрим в absolute-form
            try:
                ur, uw = await asyncio.wait_for(
                    asyncio.open_connection(UP[0], UP[1]), 25)
            except Exception:
                await reply(writer, 502, "Bad Gateway (upstream)")
                return
            abs_target = target if "://" in target \
                else "http://%s:%d%s" % (host, port, path)
            lines[0] = ("%s %s HTTP/1.1" % (method, abs_target)).encode("latin-1")
        else:
            try:
                ur, uw = await asyncio.wait_for(
                    asyncio.open_connection(host, port), 25)
            except Exception:
                await reply(writer, 502, "Bad Gateway")
                return
            lines[0] = ("%s %s HTTP/1.1" % (method, path)).encode("latin-1")
        keep = [l for l in lines if not l[:14].lower().startswith(
            (b"proxy-conn", b"proxy-auth"))]        # Proxy-* не идёт дальше
        if UP and UP[2]:   # своя авторизация к апстриму — после фильтра
            keep.append(b"Proxy-Authorization: Basic " + UP[2].encode("ascii"))
        uw.write(b"\r\n".join(keep) + b"\r\n\r\n" + rest)
        log.info("%s http://%s:%d%s%s", method, host, port, path,
                 " (upstream %s:%d)" % (UP[0], UP[1]) if UP else "")
        await asyncio.gather(pump(reader, uw), pump(ur, writer))
    except asyncio.CancelledError:
        raise
    except Exception:
        log.debug("ошибка соединения", exc_info=True)
    finally:
        try:
            writer.close()
        except Exception:
            pass


async def main():
    global UP
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=8899)
    ap.add_argument("--upstream", default="",
                    help="user:pass@host:port — выход через другой прокси")
    ap.add_argument("-v", "--verbose", action="store_true")
    args = ap.parse_args()
    if args.upstream:
        UP = _parse_upstream(args.upstream)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.WARNING,
        format="%(asctime)s %(levelname)s %(message)s")
    srv = await asyncio.start_server(handle, args.host, args.port)
    print("READY %s:%d" % srv.sockets[0].getsockname()[:2], flush=True)
    async with srv:
        await srv.serve_forever()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        sys.exit(0)
HTTPEOF
}

# ----------------------------------------------------------------------------
# встроенный SOCKS5-сервер (чистый python3, только CONNECT)
# ----------------------------------------------------------------------------
write_socks5() { # $1 = путь к файлу
  cat > "$1" <<'SOCKS5EOF'
#!/usr/bin/env python3
"""Минимальный SOCKS5-прокси (RFC 1928, только CONNECT). Зависимостей нет."""
import argparse, asyncio, ipaddress, logging, sys

log = logging.getLogger("socks5")

def fail(writer, code=0x01):
    try:
        writer.write(b"\x05" + bytes([code]) + b"\x00\x01" + b"\x00" * 6)
        writer.close()
    except Exception:
        pass

async def pump(src, dst):
    try:
        while True:
            data = await src.read(65536)
            if not data:
                break
            dst.write(data)
            await dst.drain()
    except Exception:
        pass
    finally:
        try:
            dst.close()
        except Exception:
            pass

async def handle(reader, writer):
    try:
        ver_n = await reader.readexactly(2)
        if ver_n[0] != 5 or ver_n[1] == 0:
            return fail(writer)
        await reader.readexactly(ver_n[1])
        writer.write(b"\x05\x00")            # метод 0x00 — без аутентификации
        await writer.drain()

        req = await reader.readexactly(4)     # VER CMD RSV ATYP
        _ver, cmd, _rsv, atyp = req
        if cmd != 1:                          # поддерживаем только CONNECT
            return fail(writer, 0x07)
        if atyp == 1:
            host = ".".join(str(b) for b in await reader.readexactly(4))
        elif atyp == 3:
            n = (await reader.readexactly(1))[0]
            try:
                host = (await reader.readexactly(n)).decode("ascii")
            except UnicodeDecodeError:
                return fail(writer, 0x01)
        elif atyp == 4:
            host = ipaddress.IPv6Address(await reader.readexactly(16)).compressed
        else:
            return fail(writer, 0x08)
        port = int.from_bytes(await reader.readexactly(2), "big")

        try:
            ur, uw = await asyncio.wait_for(
                asyncio.open_connection(host, port), 25)
        except Exception:
            return fail(writer, 0x05)

        writer.write(b"\x05\x00\x00\x01" + b"\x00" * 6)   # успешно
        await writer.drain()
        log.info("CONNECT %s:%s", host, port)
        await asyncio.gather(pump(reader, uw), pump(ur, writer))
    except (asyncio.IncompleteReadError, ConnectionResetError, OSError):
        pass
    except Exception:
        log.exception("ошибка соединения")
    finally:
        try:
            writer.close()
        except Exception:
            pass

async def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=1080)
    ap.add_argument("-v", "--verbose", action="store_true")
    args = ap.parse_args()
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.WARNING,
        format="%(asctime)s %(levelname)s %(message)s")
    srv = await asyncio.start_server(handle, args.host, args.port)
    print("READY %s:%d" % srv.sockets[0].getsockname()[:2], flush=True)
    async with srv:
        await srv.serve_forever()

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        sys.exit(0)
SOCKS5EOF
}

# ----------------------------------------------------------------------------
# запуск proxy.py (опциональный движок команды ip: --engine proxy.py)
# ----------------------------------------------------------------------------
start_proxypy() { # $1 = порт, $2 = временный каталог; пишет pid в $2/px.pid
  local cmd
  cmd="$(command -v proxy 2>/dev/null || true)"
  if [ -z "$cmd" ]; then
    cmd="$(find /usr/local "$HOME/.local/bin" -name proxy -type f -perm -o+x \
           2>/dev/null | head -1 || true)"
  fi
  if [ -z "$cmd" ]; then
    say "устанавливаю proxy.py (pip)…"
    local pip="python3 -m pip"
    command -v pip3 >/dev/null 2>&1 && pip="pip3"
    $pip install --quiet --disable-pip-version-check proxy.py \
        >>"$2/proxy.log" 2>&1 || return 1
    cmd="$(command -v proxy 2>/dev/null || true)"
    [ -z "$cmd" ] && cmd="$(find /usr/local "$HOME/.local/bin" \
        -name proxy -type f -perm -o+x 2>/dev/null | head -1 || true)"
    [ -z "$cmd" ] && cmd="python3 -m proxy"
  fi
  say "запускаю proxy.py: $cmd --port $1"
  $cmd --port "$1" --hostname 127.0.0.1 >"$2/proxy.log" 2>&1 &
  echo $! > "$2/px.pid"
  return 0
}

# ----------------------------------------------------------------------------
# встроенный SSH-клиент туннеля (python3 + asyncssh; нужен, если нет openssh)
# ----------------------------------------------------------------------------
ensure_asyncssh() {
  if PYLIB="$TINGY_HOME/pylib" python3 - >/dev/null 2>&1 <<'PY'
import os, sys
for lib in (os.environ.get("PYLIB", ""), os.path.expanduser("~/.tingy/pylib")):
    if lib and os.path.isdir(lib) and lib not in sys.path:
        sys.path.insert(0, lib)
try:
    import asyncssh
except Exception:
    sys.exit(1)
PY
  then return 0; fi
  say "openssh не найден — ставлю python-клиент (asyncssh) в ~/.tingy/pylib…"
  local pip_cmd="python3 -m pip"
  command -v pip3 >/dev/null 2>&1 && pip_cmd="pip3"
  mkdir -p "$TINGY_HOME"
  $pip_cmd install --quiet --disable-pip-version-check \
      --target "$TINGY_HOME/pylib" asyncssh \
    || $pip_cmd install --quiet --disable-pip-version-check \
      --break-system-packages --target "$TINGY_HOME/pylib" asyncssh \
    || return 1
  ok "asyncssh установлен в $TINGY_HOME/pylib"
  return 0
}

write_pyssh() { # $1 = путь к файлу
  cat > "$1" <<'PYCLIEOF'
#!/usr/bin/env python3
"""tingy-py — SSH-клиент туннелей pongy без openssh.

Эквивалент «ssh -p ПОРТ -R REMOTE:127.0.0.1:LOCAL -N ПОЛЬЗОВАТЕЛЬ@ХОСТ»,
но нужен только python3 и asyncssh. Наружный порт печатается строкой
    REMOTE_PORT <номер>
Режим --shell — как ssh без -N: показать баннер сервера с адресами.
"""
import argparse, asyncio, os, sys


def _add_lib(path):
    if path and os.path.isdir(path) and path not in sys.path:
        sys.path.insert(0, path)
    home = os.path.expanduser("~/.tingy/pylib")
    if os.path.isdir(home) and home not in sys.path:
        sys.path.insert(0, home)


async def run(args):
    import asyncssh

    conn = await asyncssh.connect(
        args.host, args.port, username=args.user,
        known_hosts=None, keepalive_interval=30)

    try:
        listener = await conn.forward_remote_port(
            "", args.remote, "127.0.0.1", args.local)
    except Exception as exc:
        print("не удалось запросить проброс порта: %s" % exc,
              file=sys.stderr, flush=True)
        return 1

    print("REMOTE_PORT %d" % listener.get_port(), flush=True)

    if args.shell:
        class Print(asyncssh.SSHClientSession):
            def data_received(self, data, datatype):
                sys.stdout.write(data)
                sys.stdout.flush()

        chan, _session = await conn.create_session(Print)
        await chan.wait_closed()
    else:
        await conn.wait_closed()
    return 0


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", required=True)
    ap.add_argument("--port", type=int, required=True)
    ap.add_argument("--user", required=True)
    ap.add_argument("--remote", type=int, default=0)
    ap.add_argument("--local", type=int, required=True)
    ap.add_argument("--lib", default="")
    ap.add_argument("--shell", action="store_true",
                    help="показать баннер сервера (как ssh без -N)")
    args = ap.parse_args()
    _add_lib(args.lib)
    try:
        sys.exit(asyncio.run(run(args)))
    except KeyboardInterrupt:
        sys.exit(0)
    except Exception as exc:
        print("ошибка туннеля: %s" % exc, file=sys.stderr, flush=True)
        sys.exit(1)


if __name__ == "__main__":
    main()
PYCLIEOF
}

# ----------------------------------------------------------------------------
# туннель: openssh или встроенный python-клиент (если ssh не установлен)
# ----------------------------------------------------------------------------
# $1 = пользователь (токен или ПОД__токен), $2 = локальный порт,
# $3 = временный каталог. Результат: TUNNEL_PID и RET_PORT (наружный порт).
start_tunnel() {
  local user="$1" lport="$2" T="$3"
  TUNNEL_PID=""; RET_PORT=""
  if [ -n "$SSH_BIN" ]; then
    ssh "${SSH_OPTS[@]}" -p "$TINGY_PORT" \
        -R "${OPT_REMOTE}:127.0.0.1:${lport}" -N \
        "${user}@${TINGY_HOST}" 2>"$T/ssh.err" &
    TUNNEL_PID=$!
    if [ "$OPT_REMOTE" = "0" ]; then
      RET_PORT="$(get_alloc_port "$T/ssh.err" "$TUNNEL_PID")" || {
        cat "$T/ssh.err" >&2
        [ -s "$T/ssh.err" ] || say "ssh не сообщает об ошибке — соединение висит (файрвол/прокси на пути?)"
        return 1
      }
    else
      ensure_ssh_alive "$TUNNEL_PID" || { cat "$T/ssh.err" >&2; return 1; }
      RET_PORT="$OPT_REMOTE"
    fi
    return 0
  fi

  say "openssh не найден — туннель через встроенный python-клиент"
  write_pyssh "$T/pyclient.py"
  python3 "$T/pyclient.py" --host "$TINGY_HOST" --port "$TINGY_PORT" \
      --user "$user" --remote "$OPT_REMOTE" --local "$lport" \
      --lib "$TINGY_HOME/pylib" \
      >"$T/tunnel.out" 2>"$T/tunnel.err" &
  TUNNEL_PID=$!
  local i p
  for i in $(seq 1 150); do
    p=$(grep -aoE 'REMOTE_PORT [0-9]+' "$T/tunnel.out" 2>/dev/null | head -1 \
        | grep -aoE '[0-9]+$' || true)
    if [ -n "$p" ]; then RET_PORT="$p"; return 0; fi
    kill -0 "$TUNNEL_PID" 2>/dev/null || {
      cat "$T/tunnel.err" >&2
      [ -s "$T/tunnel.err" ] || say "клиент не сообщает об ошибке — соединение висит (файрвол/прокси на пути?)"
      return 1
    }
    sleep 0.2
  done
  return 1
}

# ----------------------------------------------------------------------------
# команды
# ----------------------------------------------------------------------------
cmd_ip_run() {
  local engine="${OPT_ENGINE:-builtin}"
  case "$engine" in
    builtin|"")    engine="builtin" ;;
    proxy.py|proxypy) engine="proxy.py" ;;
    *) die "неизвестный --engine: $OPT_ENGINE (доступны: builtin, proxy.py)" ;;
  esac
  if [ -n "$OPT_UPSTREAM" ] && [ "$engine" != "builtin" ]; then
    die "--upstream поддерживается только встроенным движком (уберите --engine)"
  fi
  local T; T=$(mktemp -d "${TMPDIR:-/tmp}/tingy.XXXXXX")
  local px_pid="" ssh_pid=""
  cleanup() {
    [ -n "$px_pid" ] && kill "$px_pid" 2>/dev/null
    [ -n "$ssh_pid" ] && kill "$ssh_pid" 2>/dev/null
    rm -rf "$T"
  }
  trap cleanup EXIT INT TERM

  # Локальный порт наружу НЕ смотрит — это чисто технический порт движка
  # на этой машине. 8899 берётся, если свободен; иначе (или «random»)
  # ядро ОС само выбирает свободный порт (--port 0 у движка) — атомарно,
  # поэтому параллельные запуски и уже работающие прокси не задеваются.
  # Наружный порт в адресе выдаёт отдельно сервер (см. --remote).
  local local_port=""
  resolve_local_port 8899

  if [ "$engine" = "builtin" ]; then
    write_http_proxy "$T/httpproxy.py"
    local up_args=""
    [ -n "$OPT_UPSTREAM" ] && up_args="--upstream $OPT_UPSTREAM"
    local try
    for try in 1 2; do
      python3 "$T/httpproxy.py" --host 127.0.0.1 --port "${LOCAL_PORT:-0}" \
          $up_args >"$T/proxy.log" 2>&1 &
      px_pid=$!
      if local_port=$(get_ready_port "$T/proxy.log" "$px_pid"); then break; fi
      # дефолтный порт успели перехватить в гонке — молча берём случайный;
      # явно заданный пользователем порт — это уже честная ошибка
      if [ "$try" = "1" ] && [ "$LOCAL_MODE" = "fixed" ] \
         && [ -z "${OPT_LOCAL:-}" ]; then
        say "не удалось занять порт :$LOCAL_PORT — беру случайный свободный"
        LOCAL_MODE="random"; LOCAL_PORT=""
        continue
      fi
      cat "$T/proxy.log" >&2
      [ -n "${OPT_LOCAL:-}" ] \
        && say "порт :$OPT_LOCAL не удалось занять (занят?) — укажите другой или «random»"
      die "HTTP-прокси ($engine) не поднялся"
    done
  else
    if [ -z "$LOCAL_PORT" ]; then
      LOCAL_PORT=$(pick_free_port)
      [ -n "$LOCAL_PORT" ] || die "не удалось подобрать свободный порт"
    fi
    start_proxypy "$LOCAL_PORT" "$T" \
      || { cat "$T/proxy.log" >&2 2>/dev/null; die "не удалось запустить proxy.py"; }
    px_pid=$(cat "$T/px.pid")
    local_port="$LOCAL_PORT"
    wait_tcp 127.0.0.1 "$local_port" \
      || { cat "$T/proxy.log" >&2; die "HTTP-прокси ($engine) не поднялся на :$local_port"; }
  fi
  [ -n "$local_port" ] || die "HTTP-прокси ($engine) не поднялся"
  [ "$LOCAL_MODE" = "random" ] \
    && ok "локальный порт: $local_port (случайный свободный)"
  say "локальный HTTP-прокси: 127.0.0.1:$local_port (движок: $engine)"

  start_tunnel "$TINGY_TOKEN" "$local_port" "$T"
  ssh_pid="$TUNNEL_PID"
  local port="$RET_PORT"
  [ -n "$port" ] || die "туннель не поднялся (проверьте токен и $TINGY_HOST:$TINGY_PORT)"
  local addr="http://${TINGY_HOST}:${port}"
  if [ -n "$TINGY_BG_RESULT" ]; then
    printf 'HTTP_PROXY %s\n' "$addr" > "$TINGY_BG_RESULT"
  fi
  say "проверяю внешний IP через прокси…"
  local myip verdict title up
  myip=$(fetch_ip "$addr")
  title="HTTP-прокси готов (IP этой машины)"
  if [ -n "$OPT_UPSTREAM" ]; then
    up=$(printf '%s' "$OPT_UPSTREAM" | sed 's/.*@//')   # без пароля в баннере
    title="HTTP-прокси готов (выход: $up)"
  fi
  title="${title:0:48}"
  if [ -n "$myip" ]; then
    say "проверяю репутацию IP (ip-api.com)…"
    verdict=$(ip_quality "$addr" "$myip")
    verdict="${verdict:0:48}"
    if [ -n "$verdict" ]; then
      banner "$title" "$addr" \
             "внешний IP: $myip" \
             "$verdict" \
             "используйте: curl -x $addr любой_URL"
    else
      banner "$title" "$addr" \
             "внешний IP: $myip" \
             "используйте: curl -x $addr любой_URL"
    fi
    # честная подсказка: если IP в базах антифрода — так и говорим
    case "$verdict" in
      *proxy*|*hosting*)
        if [ -z "$OPT_UPSTREAM" ]; then
          say "IP помечен как дата-центр — многие сайты будут просить «смените прокси»."
          say "  лечится выходом через чистый прокси: --upstream user:pass@host:port"
        fi
        ;;
    esac
  else
    banner "HTTP-прокси готов" "$addr" \
           "проверка: curl -x $addr https://httpbin.org/ip"
  fi
  wait "$ssh_pid"
}

cmd_socks_run() {
  local T; T=$(mktemp -d "${TMPDIR:-/tmp}/tingy.XXXXXX")
  local socks_pid="" ssh_pid=""
  cleanup() {
    [ -n "$socks_pid" ] && kill "$socks_pid" 2>/dev/null
    [ -n "$ssh_pid" ] && kill "$ssh_pid" 2>/dev/null
    rm -rf "$T"
  }
  trap cleanup EXIT INT TERM

  # 1080, если свободен; иначе (или «random») — случайный свободный порт.
  # Выбор атомарный (ядро ОС), параллельные запуски не конфликтуют.
  local local_port=""
  resolve_local_port 1080
  write_socks5 "$T/socks5.py"
  local try
  for try in 1 2; do
    python3 "$T/socks5.py" --host 127.0.0.1 --port "${LOCAL_PORT:-0}" \
        >"$T/socks.log" 2>&1 &
    socks_pid=$!
    if local_port=$(get_ready_port "$T/socks.log" "$socks_pid"); then break; fi
    if [ "$try" = "1" ] && [ "$LOCAL_MODE" = "fixed" ] \
       && [ -z "${OPT_LOCAL:-}" ]; then
      say "не удалось занять порт :$LOCAL_PORT — беру случайный свободный"
      LOCAL_MODE="random"; LOCAL_PORT=""
      continue
    fi
    cat "$T/socks.log" >&2
    [ -n "${OPT_LOCAL:-}" ] \
      && say "порт :$OPT_LOCAL не удалось занять (занят?) — укажите другой или «random»"
    die "SOCKS5 не поднялся"
  done
  [ -n "$local_port" ] || die "SOCKS5 не поднялся"
  [ "$LOCAL_MODE" = "random" ] \
    && ok "локальный порт: $local_port (случайный свободный)"
  say "локальный SOCKS5: 127.0.0.1:$local_port"

  start_tunnel "$TINGY_TOKEN" "$local_port" "$T"
  ssh_pid="$TUNNEL_PID"
  local port="$RET_PORT"
  [ -n "$port" ] || die "туннель не поднялся (проверьте токен и $TINGY_HOST:$TINGY_PORT)"
  local addr="socks5://${TINGY_HOST}:${port}"
  if [ -n "$TINGY_BG_RESULT" ]; then
    printf 'SOCKS5 %s\n' "$addr" > "$TINGY_BG_RESULT"
  fi
  banner "SOCKS5-прокси готов" "$addr" "настройки браузера: SOCKS5 $TINGY_HOST $port"
  wait "$ssh_pid"
}

cmd_forward_run() {
  local local_port="$OPT_LOCAL"
  [ -n "$local_port" ] || die "укажите локальный порт: tingy.sh forward 8888"
  local T; T=$(mktemp -d "${TMPDIR:-/tmp}/tingy.XXXXXX")
  local ssh_pid=""
  cleanup() { [ -n "$ssh_pid" ] && kill "$ssh_pid" 2>/dev/null; rm -rf "$T"; }
  trap cleanup EXIT INT TERM

  start_tunnel "$TINGY_TOKEN" "$local_port" "$T"
  ssh_pid="$TUNNEL_PID"
  local port="$RET_PORT"
  [ -n "$port" ] || die "туннель не поднялся (порт --remote занят?)"
  local addr="${TINGY_HOST}:${port}"
  if [ -n "$TINGY_BG_RESULT" ]; then
    printf 'TUNNEL %s\n' "$addr" > "$TINGY_BG_RESULT"
  fi
  banner "TCP-туннель готов" "$addr" "подключайтесь к $addr"
  wait "$ssh_pid"
}

cmd_web() {
  local local_port="$OPT_LOCAL"
  [ -n "$local_port" ] || die "укажите локальный порт: tingy.sh web 8000"
  local user="${TINGY_TOKEN}"
  [ -n "$OPT_SUB" ] && user="${OPT_SUB}__${TINGY_TOKEN}"

  if [ -n "$OPT_SUB" ]; then
    # фиксированный поддомен: адрес известен заранее, работаем с -N
    local T; T=$(mktemp -d "${TMPDIR:-/tmp}/tingy.XXXXXX")
    local ssh_pid=""
    cleanup() { [ -n "$ssh_pid" ] && kill "$ssh_pid" 2>/dev/null; rm -rf "$T"; }
    trap cleanup EXIT INT TERM
    start_tunnel "$user" "$local_port" "$T"
    ssh_pid="$TUNNEL_PID"
    [ -n "${RET_PORT:-}" ] || die "туннель не поднялся (поддомен или порт занят?)"
    banner "HTTP-туннель готов" "http://${OPT_SUB}.${TINGY_HOST}"
    wait "$ssh_pid"
  else
    # случайный поддомен: сервер сам печатает адрес (режим без -N / --shell)
    local T; T=$(mktemp -d "${TMPDIR:-/tmp}/tingy.XXXXXX")
    if [ -n "$SSH_BIN" ]; then
      exec ssh "${SSH_OPTS[@]}" -p "$TINGY_PORT" \
          -R "${OPT_REMOTE}:127.0.0.1:${local_port}" \
          "${user}@${TINGY_HOST}"
    fi
    say "openssh не найден — туннель через встроенный python-клиент"
    write_pyssh "$T/pyclient.py"
    exec python3 "$T/pyclient.py" --host "$TINGY_HOST" --port "$TINGY_PORT" \
        --user "$user" --remote "$OPT_REMOTE" --local "$local_port" \
        --lib "$TINGY_HOME/pylib" --shell
  fi
}

cmd_status() {
  python3 - "$TINGY_HOST" "$TINGY_TOKEN" <<'PY'
import json, sys, urllib.parse, urllib.request

host, token = sys.argv[1], sys.argv[2]
url = "http://%s/_api/tunnels?token=%s" % (host, urllib.parse.quote(token))
try:
    with urllib.request.urlopen(url, timeout=10) as r:
        data = json.load(r)
except Exception as exc:
    print("не удалось получить статус: %s" % exc)
    sys.exit(1)
if not data.get("tunnels"):
    print("активных туннелей нет")
else:
    print("домен: %s" % data["domain"])
    for t in data["tunnels"]:
        print("  %-16s http://%s.%s   tcp %s:%d   up %ds  conns %d" % (
            t["label"], t["subdomain"], data["domain"], data["domain"],
            t["tcp_port"], t["uptime"], t["conns"]))
PY
}

cmd_stop() {
  local pidfile pid
  for pidfile in "$TINGY_HOME/tingy.pid" "$TINGY_HOME/proxy.pid"; do
    [ -f "$pidfile" ] || continue
    pid=$(cat "$pidfile" 2>/dev/null)
    if [ -n "$pid" ] && kill "$pid" 2>/dev/null; then
      ok "остановлен (pid $pid)"
    else
      say "процесс ${pid:-?} уже не работает"
    fi
    rm -f "$pidfile"
    return 0
  done
  die "фоновых туннелей нет ($TINGY_HOME/*.pid)"
}

# ----------------------------------------------------------------------------
# фоновый режим (общий для ip и socks)
# ----------------------------------------------------------------------------
run_daemon() { # $1 = команда (ip|socks); печатает адрес и выходит
  mkdir -p "$TINGY_HOME"
  local RES="$TINGY_HOME/last_address"
  local PIDF="$TINGY_HOME/tingy.pid"
  rm -f "$RES" "$PIDF"
  local extra=()
  [ -n "$OPT_ENGINE" ] && extra=(--engine "$OPT_ENGINE")
  local launch=(
    env TINGY_BG_RESULT="$RES" nohup bash "$0" "$1"
    ${OPT_LOCAL:+"$OPT_LOCAL"} --remote "$OPT_REMOTE"
    --token "$TINGY_TOKEN" --host "$TINGY_HOST" --port "$TINGY_PORT"
    ${extra[@]+"${extra[@]}"}
  )
  if command -v setsid >/dev/null 2>&1; then
    setsid "${launch[@]}" >>"$TINGY_HOME/daemon.log" 2>&1 </dev/null &
  else
    "${launch[@]}" >>"$TINGY_HOME/daemon.log" 2>&1 </dev/null &
  fi
  echo $! > "$PIDF"
  for _ in $(seq 1 150); do
    [ -s "$RES" ] && { head -1 "$RES"; exit 0; }
    sleep 0.2
  done
  die "не дождались адреса (см. $TINGY_HOME/daemon.log)"
}

# ----------------------------------------------------------------------------
# диспетчер
# ----------------------------------------------------------------------------
case "$CMD" in
  ip)
    require_token; need python3; need_tunnel_client
    [ "$OPT_DAEMON" = "1" ] && run_daemon ip
    cmd_ip_run
    ;;
  socks)
    require_token; need python3; need_tunnel_client
    [ "$OPT_DAEMON" = "1" ] && run_daemon socks
    cmd_socks_run
    ;;
  forward)
    require_token; need_tunnel_client
    cmd_forward_run
    ;;
  web)
    require_token; need_tunnel_client
    cmd_web
    ;;
  status)
    require_token; need python3
    cmd_status
    ;;
  stop)
    cmd_stop
    ;;
  *) usage; exit 1 ;;
esac
