#!/bin/bash

## Copyright (c) 2007 Stefan Behte <stefan.behte@gmx.net> <http://ge.mine.nu/>
## Copyright (C) 2013 - 2025 ENCRYPTED SUPPORT LLC <adrelanos@riseup.net>
## Copyright (C) 2021 - 2022 nyxnor <nyxnor@protonmail.com>
## See the file COPYING for copying conditions.

## Command line tool for setting up stream for communication from the
## Tor Controller's (client) to a Tor process (server). The client send
## commands using TCP sockets or Unix-domain sockets and receive replies
## from the server.
##
## In order to get this to work, read tor-ctrl's and tor's man page
## to configure the control method (ControlPort/ControlSocket) and the
## authentication method (CookieAuthentication/HashedControlPassword).

# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/strings.bsh
source /usr/libexec/helper-scripts/strings.bsh

## script version
torctrl_version="5.0.0"

## script name
me="${0##*/}"

usage(){
  printf '%s\n' "usage: ${me}

  -c [command]   command to execute
                 notice: recommended to \"double quote\" your command
                 notice: separate control commands with a shell escape to new
                         line '\\\n'
                 notice: '-c' is optional when command is the last positional
                         argument

  --tbb          use Tor Browser Bundle socket
                 default: socket specified by the '-s' option
                 default: if fails use environment variables
                          TOR_CONTROL_HOST:TOR_CONTROL_PORT
                 default:  if it also fails, try 127.0.0.1:9151

  -s [socket]    use specified tor's control socket
                 notice: tcp sockets format: [addr:]port: 9051, 127.0.0.1:9051
                 notice: unix sockets format: [unix:]path: /run/tor/control,
                         unix:/run/tor/control
                 notice: stops at first successful connection.
                 default: socket specified by this option,
                 default: if fails use environment variables
                          TOR_CONTROL_HOST:TOR_CONTROL_PORT
                 default: if fails search for socket on tor configuration files
                 default: if it also fails, try 127.0.0.1:9051

  -p [pwd]       use password instead of Tor's control_auth_cookie
                 default: not used

  -t [time]      sleep [time] seconds after each command sent
                 notice: if the socket program is telnet, fallback to 1 second
                 default: 0 second

  -w             after sending the command, wait for interrupt signal (Ctrl+C)
                 before closing the connection.
                 notice: useful to be informed about events (asynchronous
                         replies), command SETEVENTS
                 notice: incompatible with '-q' quiet mode.
                 notice: exit code shall not be evaluated.
                 default: not set

  -m             machine mode
                 notice: script informational and warning messages won't be
                         printed to stdout
                 default: not set

  -q             quiet mode.
                 default: not set.

  -V             print version information and exit

Debugging options:

  -d             debug mode.
                 default: not set

  -r             dry-run mode
                 notice: Show what would be done, without sending commands to
                         the controller
                 default: not set

  Examples:      ${me} GETCONF User
                 ${me} -q SETCONF bandwidthrate=1mb
                 ${me} -s 9051 -p foobar GETCONF bandwidthrate
                 ${me} GETINFO config-defaults-file config-file config-text
                 ${me} GETCONF SocksPort \\\n GETINFO net/listeners/socks
                 ${me} -w -- SETEVENTS STREAM

You should have a look at
https://gitweb.torproject.org/torspec.git/tree/control-spec.txt"
  exit 1
}

## wrapper to printf
notice(){
  { [ -n "${quiet}" ] || [ -n "${machine_mode}" ] ; } && return
  printf '%s\n' "${me}: [notice] ${1}"
}

## print what failed, redirect output to stderr and exit with failed status
error_msg(){
  printf '%s\n' "${me}: [error] ${1}" >&2
  exit 1
}

## dylanaraps/pfetch has function
## fail if program is not installed on path or not executable
has() {
  _cmd=$(command -v "${1}") 2>/dev/null || return 1
  [ -x "${_cmd}" ] || return 1
}

## check if required programs are installed
check_progs(){
  prog_net="nc netcat socat telnet"
  for n in ${prog_net}; do
    has "${n}" && socket_prog="${n}" && break
  done
  [ -z "${socket_prog}" ] && error_msg "Install one of the following programs to connect to sockets: ${prog_net}"

  case "${socket_prog##*/}" in
    telnet) : "${sleep_n:=1}";;
    *) : "${sleep_n:=0}";;
  esac
}

## if socket was not assigned on the command line, try auto-detection
get_socket(){
  ## workstation can't run tor, it will hang
  test -f /usr/share/anon-ws-base-files/workstation && return 0

  ## minimal check for tor service manager file
  test -f /lib/systemd/system/tor@default.service && tor_start_command="$(grep -- "ExecStart=" /lib/systemd/system/tor@default.service | sed "s/ExecStart=//g")"

  ## don't evaluate the exit code of '--verify-config' as it will fail if
  ## not running as root or the tor user
  ## if 'default_torrc' is empty, tor will try: @CONFDIR@/torrc-defaults.
  ## if 'f_torrc' is empty, tor will try: @CONFDIR@/torrc, or $HOME/.torrc
  ## if that file is not found
  ## example line that has to be cleanse: print only last arg in line,
  ## remove '"', remove '.', remove '//'.
  ## Jan 1 00:00:00.000 [notice] Including configuration file
  ## "/usr/local/etc/torrc.d//50_user.conf".
  # shellcheck disable=SC2086
  tor_config_files="$(${tor_start_command:-tor} --verify-config | grep -E -- " Read configuration file [^ ]*| Including configuration file [^ ]*" | awk '{print $NF}' | sed "s/\"//;s/\".//;s/\/\//\//")"

  ## grepping no file will be stuck forever
  if [ -n "${tor_config_files}" ]; then
    ## only one option is needed, use head
    # shellcheck disable=SC2086
    tor_control_socket_filesystem="$(grep -E -- "^ControlPort [^ ]*|^ControlSocket [^ ]*" ${tor_config_files} | cut -d ":" -f2- | cut -d " " -f2 | sed "s/\"//g;s/unix\://" | head -n 1)"
  fi
}

check_socket(){
  ## find if socket is a unix domain socket or a TCP socket and do sanity checks
  socket="${1}"
  [ -z "${socket}" ] && return 1
  case "${socket}" in
    "unix:/"*|"/"*)
      socket_type="unix"
      tor_control_unix="${socket##*unix:}"
      test -e "${tor_control_unix}" || {
        printf '%s\n' "${tor_control_unix} does not exist" | tee -a -- "${temp_dir}/fail.log" >/dev/null
        return 1
      }
      test -S "${tor_control_unix}" || {
        printf '%s\n' "${tor_control_unix} is not a socket" | tee -a -- "${temp_dir}/fail.log" >/dev/null
        return 1
      }
    ;;
    [0-9]*|":"[0-9]*)
      ## latter option occurs when TOR_CONTROL_HOST is empty but TOR_CONTROL_PORT is not
      socket_type="tcp"
      tor_control_port="${socket##*:}"
      tor_control_port="${tor_control_port:-9051}"
      printf '%d' "${tor_control_port}" >/dev/null 2>&1 || {
        printf '%s\n' "${tor_control_port} is not a valid port, not an integer" | tee -a -- "${temp_dir}/fail.log" >/dev/null
        return 1
      }
      { [ "${tor_control_port}" -gt 0 ] && [ "${tor_control_port}" -le 65535 ]; } || { printf '%s\n' "${tor_control_port} is not a valid port, not within range: 0-65535" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
      tor_control_host="${socket%%:*}"
      { [ "${tor_control_host}" = "${tor_control_port}" ] || [ -z "${tor_control_host}" ]; } && tor_control_host="127.0.0.1"
      for quad in $(printf '%s\n' "${tor_control_host}" | tr "." " "); do
        printf '%d' "${quad}" >/dev/null 2>&1 || { printf '%s\n' "${tor_control_host} is not a valid address, ${quad} is not and integer" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
        { [ "${quad}" -ge 0 ] && [ "${quad}" -le 255 ]; } || { printf '%s\n' "${tor_control_host} is not a valid address, ${quad} is not within range: 0-255" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
      done
    ;;
    ":") return 1;; ## this happens when variables are empty
    *) printf '%s\n' "socket '${socket}' is invalid" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1;;
  esac

  ## assign the program options for socket and test if connection is refused
  case "${socket_type}" in
    unix)
      case "${socket_prog##*/}" in
        nc|netcat)
          socket_prog_connect="${socket_prog} -U ${tor_control_unix}"
          ${socket_prog_connect} -zv || { printf '%s\n' "${socket_prog}: Connection refused to ${tor_control_unix}" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
        ;;
        socat)
          socket_prog_connect="${socket_prog} - UNIX-CONNECT:${tor_control_unix}"
          ${socket_prog} /dev/null "UNIX-CONNECT:${tor_control_unix}" || { printf '%s\n' "${socket_prog}: Connection refused to ${tor_control_unix}" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
        ;;
        *) printf '%s\n' "${socket_prog##*/} doesn't support Unix-domain sockets" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1;;
      esac

      tor_socket="${tor_control_unix}"
    ;;
    tcp)
      case "${socket_prog##*/}" in
        nc|netcat)
          socket_prog_connect="${socket_prog} ${tor_control_host} ${tor_control_port}"
          ${socket_prog_connect} -z || { printf '%s\n' "${socket_prog}: Connection refused to ${tor_control_host}:${tor_control_port}" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
        ;;
        socat)
          socket_prog_connect="${socket_prog} TCP:${tor_control_host}:${tor_control_port} -"
          "${socket_prog}" "TCP:${tor_control_host}:${tor_control_port}" /dev/null || { printf '%s\n' "${socket_prog}: Connection refused to ${tor_control_host}:${tor_control_port}" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
        ;;
        telnet)
          socket_prog_connect="${socket_prog} ${tor_control_host} ${tor_control_port}"
          printf "\x1dclose\x0d" | ${socket_prog_connect} 2>/dev/null | grep -q -- "Connected to ${tor_control_host}" || { printf '%s\n' "${socket_prog}: Connection refused to ${tor_control_host}:${tor_control_port}" | tee -a -- "${temp_dir}/fail.log" >/dev/null; return 1; }
        ;;
      esac

      tor_socket="${tor_control_host}:${tor_control_port}"
    ;;
  esac
}

## get string to authenticate to the controller
login(){
  auth="$({ send_cmd "PROTOCOLINFO"; send_cmd "QUIT"; } | ${socket_prog_connect} 2>/dev/null | grep -- "AUTH")"
  auth_methods="$(printf '%s\n' "${auth}" | sed "s/250-AUTH METHODS=//" | cut -d " " -f1 | tr "," "\n")"
  tor_cookie="$(printf '%s\n' "${auth}" | sed "s/.*COOKIEFILE=//" | tr -d "\"" | tr -d "\\r")"

  if test -r "${tor_cookie}"; then
    if [ ! "$tor_cookie" = "/run/tor/control.authcookie" ]; then
      error_msg "Cookie file location other than '/run/tor/control.authcookie' currently unsupported."
    fi
  fi

  if printf '%s\n' "${auth_methods}" | grep -q -- "NULL"; then
    auth_string="AUTHENTICATE"
  elif test -r "${tor_cookie}"; then
    safecookie_enabled="$(printf '%s\n' "${auth_methods}" | grep -- "^SAFECOOKIE$")"
    cookie_enabled="$(printf '%s\n' "${auth_methods}" | grep -- "^COOKIE$")"
    if [ -n "${cookie_enabled}" ]; then
      ## xxd is only needed when authenticating with SAFECOOKIE
      prog_converter_hex="xxd hexdump od"
      for n in ${prog_converter_hex}; do
        has "${n}" && hex_prog="${n}" && break
      done
      [ -z "${hex_prog}" ] && error_msg "Missing program to dump hex. Install either 'hexdump' or 'xxd' or 'od'."
      case "${hex_prog}" in
        xxd) auth_string="AUTHENTICATE $(xxd -u -p -c 32 "${tor_cookie}")";;
        hexdump) auth_string="AUTHENTICATE $(hexdump -e '32/1 "%02x""\n"' "${tor_cookie}")";;
        od) auth_string="AUTHENTICATE $(od -t x1 "${tor_cookie}" | head -n 2 | cut -d " " -f2- | tr -d "\n" | tr -d " ")";;
      esac
    fi
  elif
    # shellcheck disable=SC2154
    [ -n "${tor_password}" ] && printf '%s\n' "${auth_methods}" | grep -q -- "^HASHEDPASSWORD$" ; then
    auth_string="AUTHENTICATE \"${tor_password}\""
  fi

  if [ -z "${auth_string}" ]; then
    auth_failed=""
    printf '%s\n' "${me}: Authentication method not detected."
    if printf '%s\n' "${auth_methods}" | grep -q -- "^COOKIE$" && ! test -r "${tor_cookie}"; then
      printf '%s\n' "${me}: COOKIE is enabled but not readable by user ${USER}."
      auth_failed=1
    fi
    if printf '%s\n' "${auth_methods}" | grep -q -- "^HASHEDPASSWORD$" && [ -z "${tor_password}" ]; then
      printf '%s\n' "${me}: HASHEDPASSWORD is enabled but no password provided on the command line."
      auth_failed=1
    fi
    ## this will only run if it can connect to the socket but is not receiving replies expected by a tor's controller
    if [ -z "${auth_failed}" ]; then
      printf '%s\n' "${me}: ${tor_control_host}:${tor_control_port} does not seems to be tor's controller socket."
    fi
    exit 1
  fi
}

## just get variables, don't send commands to the controller
get_dry_run(){
  for key in cmd_cli tor_socket tor_password sleep_n quiet wait_confirmation debug_mode dry_run; do
    eval val='$'"${key}" && printf '%s\n' "${key}=${val:-""}"
  done
  exit 0
}

## send command to the controller and sleep after it if required
send_cmd(){
  ## avoid sending empty lines and cycle sleep for every line sent if cmd_cli has multiple lines
  printf '%s\n' "${1}" | while IFS="$(printf '\n')" read -r line; do
    [ -n "${line}" ] && { printf '%s\n' "${line}"; sleep "${sleep_n}"; }
  done
}

## piping commands to be sent
cmd_pipe(){
  # shellcheck disable=SC2030,2031
  send_cmd "${auth_string}"
  send_cmd "${cmd_cli}"
  # shellcheck disable=SC2034
  if [ -n "${wait_confirmation}" ]; then
    ## infinite loop that can be terminated with interrupt signal
    while :; do
      sleep 24h
    done
  fi
  send_cmd "QUIT"
}

## get result, be verbose by default and quiet if required, set exit code
finish(){
  str="$(cat)"
  [ -z "${quiet}" ] && printf '%s\n' "${str}"
  counter="$(printf '%s\n' "${str}" | grep -c -- "^250 ")"
  ## more than 3 '^250 ' reply status code occurs when $cmd_cli has multiple lines (\\n)
  if [ "$counter" -ge "3" ]; then
    exit 0
  else
    exit 1
  fi
}

exit_handler() {
  trap "" EXIT
  if ! test -d "${temp_dir}" ; then
    return 0
  fi
  safe-rm -r -f -- "${temp_dir}"
  return 0
}

############
### opts ###

## if option requires argument, check if it was provided, if yes, assign the arg to the opt
get_arg(){
  ## if argument is empty or starts with '-', fail as it possibly is an option
  case "${arg}" in
    ""|-*) error_msg "Option '${opt}' requires an argument.";;
  esac
  value="${arg}"
  ## Escaping quotes is needed because else it will fail if the argument is quoted
  # shellcheck disable=SC2140
  check_variable_name "${1}" || exit 1
  eval "${1}"="\"${value}\""
}

## hacky getopts
while :; do
  case "${1}" in
    --?*) opt="${1#*--}"; arg="${2:-}";;
    -?*) opt="${1#*-}"; arg="${2:-}";;
    *) opt="${1:-}";;
  esac
  case "${opt}" in
    c) get_arg cmd_cli; shift 2;;
    s) get_arg tor_control_socket; shift 2;;
    p) get_arg tor_password; shift 2;;
    t) get_arg sleep_n; shift 2;;
    q) quiet=1; shift 1;;
    w) wait_confirmation=1; shift 1;;
    d) debug_mode=1; shift 1;;
    r) dry_run=1; shift 1;;
    m) machine_mode=1; shift 1;;
    tbb) torbrowser=1; shift 1;;
    h) usage;;
    V) printf '%s\n' "tor-ctrl ${torctrl_version}"; exit 0;;
    -) shift 1; break;;
    "") break;;
    *) break;; ## it could be be usage instead of break, but then would need to use '--' to end option parsing
  esac
done

############
### main ###
temp_dir="$(mktemp --directory)"

# sig_intr="$(stty -a | sed -n '/.*intr = / {s///;s/;.*$//;s/\^//p;}')"
# sig_quit="$(stty -a | sed -n '/.*quit = / {s///;s/;.*$//;s/\^//p;}')"

## check for debug mode
[ -n "${debug_mode}" ] && set -x

#[ -n "${quiet}" ] && exec >/dev/null 2>&1

## if command was not assigned by '-c' option, get remaining arguments as command
: "${cmd_cli:="${*}"}"

if [ -n "${cmd_cli}" ]; then
  ## accept commands separated by new lines
  cmd_cli="$(printf "%s\n" "${cmd_cli}" | sed "s|^ ||")"
  ## check if requirements are fulfilled
  check_progs
  ## sleep was assigned on check_progs if empty because of telnet, see if it is valid.
  if ! printf '%d' "${sleep_n}" >/dev/null 2>&1 && ! printf '%g' "${sleep_n}" >/dev/null 2>&1; then
    error_msg "Sleep time '${sleep_n}' is not an integer nor a floating point."
  fi
  ## set options for the socket program
  trap "exit_handler" EXIT INT TERM
  if [ -n "${tor_control_socket}" ]; then
    notice "attempting to connect to specified socket ${tor_control_socket}"
    check_socket "${tor_control_socket}" && socket_found=1
  fi
  if [ -n "${TOR_CONTROL_PORT}" ]; then
    notice "attempting to connect to tor's environment variables socket ${TOR_CONTROL_HOST:-"127.0.0.1"} ${TOR_CONTROL_PORT} (TOR_CONTROL_HOST TOR_CONTROL_PORT)"
    check_socket "${TOR_CONTROL_HOST}:${TOR_CONTROL_PORT}" && socket_found=1
  fi
  if [ -z "${socket_found}" ]; then
    if [ -n "${torbrowser}" ]; then
      while :; do
        notice "attempting to connect to fallback socket 127.0.0.1:9151"
        check_socket "127.0.0.1:9151" && break
        error_msg "couldn't connect to Tor Browser Bundle socket. Error logs:
        $(cat -- "${temp_dir}/fail.log")"
      done
    else
      while :; do
        get_socket
        if [ -n "${tor_control_socket_filesystem}" ]; then
          notice "attempting to connect to tor's configuration files socket ${tor_control_socket_filesystem}"
          check_socket "${tor_control_socket_filesystem}" && break
        fi
        notice "attempting to connect to fallback socket 127.0.0.1:9051"
        check_socket "127.0.0.1:9051" && break
        error_msg "couldn't connect to tor daemon socket. Error logs:
        $(cat -- "${temp_dir}/fail.log")"
      done
    fi
  fi
  safe-rm -f -- "${temp_dir}/fail.log"

  ## just get variables, don't send commands to the controller
  [ "${dry_run}" = "1" ] && get_dry_run

  ## get string to authenticate to the controller
  login

  ## disable safecookie as it is not working https://github.com/nyxnor/tor-ctrl/issues/1
  safecookie_enabled=""
  if [ -n "${safecookie_enabled}" ] && test -r "${tor_cookie}" && has xxd && test -c /dev/urandom; then
    {
      cookie_string="$(xxd -u -p -c 32 "${tor_cookie}")"
      client_nonce="$(xxd -u -p -l 32 -c 32 < /dev/urandom)"
      ## TODO server_nonce is not working, how to solve a challenge and continue answering the server?
      #send_cmd "AUTHCHALLENGE SAFECOOKIE ${client_nonce}"
      server_nonce="$(send_cmd "AUTHCHALLENGE SAFECOOKIE ${client_nonce}")"
      server_nonce="$(printf '%s\n' "${server_nonce}" | sed "s/.* SERVERNONCE=//" | tr -d "\\r")"
      #server_nonce="$(printf '%s\n' "$(send_cmd "AUTHCHALLENGE SAFECOOKIE ${client_nonce}" 3>&1)" | sed "s/.* SERVERNONCE=//" | tr -d "\\r")"
      # shellcheck disable=SC2030
      auth_string="AUTHENTICATE $(printf '%s%s%s\n' "${cookie_string}" "${client_nonce}" "${server_nonce}" | xxd -r -p | openssl dgst -sha256 -binary -hmac "Tor safe cookie authentication controller-to-server hash" | xxd -p -u -c 32)"
      send_cmd "${auth_string}"
      send_cmd "${cmd_cli}"
      # shellcheck disable=SC2034
      [ -n "${wait_confirmation}" ] && read -r confirmation
      #send_cmd "QUIT"
    } 3>&1 2>/dev/null | ${socket_prog_connect} | finish
  else
    if [ -n "${wait_confirmation}" ]; then
      notice "connecting to socket with command: ${socket_prog_connect}"
      notice "to stop listening and show a summary, please Ctrl+C"
      cmd_pipe | ${socket_prog_connect} 2>/dev/null
    else
      ## grep this line on the other extras scripts when it is fit
      notice "connecting to socket with command: ${socket_prog_connect}"
      cmd_pipe | ${socket_prog_connect} 2>/dev/null | finish
    fi
  fi
else
  usage
fi
