#!/bin/bash

## ADD_ONION - https://gitweb.torproject.org/torspec.git/tree/control-spec.txt#n1748
## DEL_ONION - https://gitweb.torproject.org/torspec.git/tree/control-spec.txt#n1925
## ONION_CLIENT_AUTH_ADD - https://gitweb.torproject.org/torspec.git/tree/control-spec.txt#n1990
## ONION_CLIENT_AUTH_REMOVE - https://gitweb.torproject.org/torspec.git/tree/control-spec.txt#n2033
## ONION_CLIENT_AUTH_VIEW - https://gitweb.torproject.org/torspec.git/tree/control-spec.txt#n2051

## Once created the new Onion Service will remain active until either:
##  - the Onion Service is removed via "DEL_ONION",
##  - the server terminates,
##  - the control connection that originated the "ADD_ONION" command is closed.
## It is possible to override disabling the Onion Service on control connection close by specifying the "Detach" flag.

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

usage(){
  printf '%s\n' "usage: ${me} [-mh] [-s socket] [-p password]

  -s [socket]    tor's control socket
                 notice: tcp: [addr:]port: 9051, 127.0.0.1:9051
                 notice: unix: [unix:]path: /run/tor/control,
                         unix:/run/tor/control
                 default: 9051

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

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

  -h             print this help message

  -o             tor hostname, also known as HSAddress or ServiceId
                 notice: '.onion' is optional, it will be stripped

SERVER

 Add onion:

  -A             add onion
                 default: not set

  -k [key_type:key_blob]
                 use key specified by the argument
                 if 'key_type' is NEW, will create a new key using the
                   'key_blob' BEST algorithm, which currently is ED25519-V3
                 if 'key_type' is an algorithm, e.g. ED25519-V3, 'key_blob' is
                   the private key in base64
                 notice: useful to restore keys: 'ED25519-V3:onion_private_key
                 default: NEW:BEST

  -l [virtport,[target]]
                 notice: virtport: The virtual TCP Port for the Onion Service
                 notice: target: The (optional) target for the given VirtPort
                 default: not set

  -u [client_pub_key]
                 client public key in base64 encoding

  -w             don't detach onion service from controller
                 notice: the service will remain active until Ctrl+C
                 notice: the service won't be shown by 'GETINFO onions/current'
                 default: not set

  -i             discard service private key
                 notice: this is irreversible, it will not be possible to
                         recreate the same hostname

  -y             add a non-anonymous Single Onion Service.

  -x [max_streams]
                 when the number of max stream is reached, close circuit.

  -P [dir]       directory where to save permanent onions

  -E [dir|file]  directory or file search for onion(s) to restore

 Delete onion:

  -D             delete onion
                 notice: service is the hostname without '.onion'

 List onions:

  -L             a newline-separated list of the detached services created


CLIENT

 Add onion client authorization:

  -U [key_type:client_priv_key]
                 add client-side v3 client auth credentials for the onion
                 service
                 key_type: 'x2551' is the only one supported right now
                 client_priv_key: base64 encoding of x25519 key

  -n             permanent flag: this client's credentials should be stored in
                 the filesystem.
                 notice: If this is not set, the client's credentials are
                         ephemeral and stored in memory.

 Remove onion client authorization:

  -R             remove the client-side v3 client auth credentials for the
                 onion service

 View onion client authorizations:

  -W             view client-side credentials for specified onion service or
                 all credentials if none service was provided

Examples:
  Server
                 ${me} -A -l 80
                 ${me} -A -l 80,127.0.0.1:8080 -w -i
                 ${me} -A -l 80 -w -i -u client_pub_key
                 ${me} -A -P ~/.${me} -l 80
                 ${me} -A -E ~/.${me}
                 ${me} -D -o website.onion
                 ${me} -L
  Client
                 ${me} -U x25519:client_priv_key_base64 -o website.onion
                 ${me} -R -o website.onion
                 ${me} -W
                 ${me} -W -o website.onion
"
  exit 1
}

error_msg(){
  printf "%s\n" "${me}: ${1}" >&2
  exit 1
}

validate_onion(){
  onion_to_validate="$1"
  ## remove .onion suffix and everything after it
  onion_to_validate=${onion_to_validate%%.onion*}
  ## remove protocol (http://, git:// etc)
  onion_to_validate=${onion_to_validate#*://}
  ## count characters
  [ "${#onion_to_validate}" = "56" ] || error_msg "Onion '${onion_to_validate}' is invalid
  length '${#onion_to_validate}' should be exactly 56 characters long (not counting .onion)"
  ## check for base32 and lower case letters
  [ -n "${onion_to_validate%%*[^a-z2-7]*}" ] || error_msg "Onion '${onion_to_validate}' is invalid
  it is not within base32 alphabet lower-case encoding [a-z2-7]"
  printf "%s\n" "${onion_to_validate}"
}

exit_handler() {
  trap "" EXIT
  if ! test -d "${permanent_onion_save%*/}" ; then
    return 0
  fi
  safe-rm -f -- "${permanent_onion_save%*/}/onion.tmp"
  return 0
}

command -v tor-ctrl >/dev/null || error_msg "Install tor-ctrl"

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

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}"
  # shellcheck disable=SC2140
  eval "${1}"="\"${value}\""
}

## hacky getopts
while :; do
  case "${1}" in
    -*) opt="${1#*-}"; arg="${2}";;
    *) opt="${1}";;
  esac
  case "${opt}" in
    o) get_arg onion; shift 2;;

    ## server
    A) add_onion=1; shift 1;;
    k) get_arg key; shift 2;;
    l) get_arg port; shift 2;;
    w) wait_confirmation=1; shift 1;;
    u) get_arg client_pub_key; shift 2;;
    i) discard_pk=1; shift 1;;
    y) non_anonymous=1; shift 1;;
    x) get_arg max_streams; shift 2;;
    P) get_arg permanent_onion_save; shift 2;;
    E) get_arg permanent_onion_restore; shift 2;;

    L) list_onion=1; shift 1;;
    D) del_onion=1; shift 1;;

    ## client
    U) get_arg onion_client_auth_add; shift 2;;
    n) permanent_auth=1; shift 1;;
    R) onion_client_auth_remove=1; shift 1;;
    W) onion_client_auth_view=1; shift 1;;

    ## general
    s) get_arg tor_control_socket; shift 2;;
    p) get_arg tor_password; shift 2;;
    m) machine_mode=1; shift 1;;

    h) usage;;
    -) 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

cli_args=""
[ -n "${tor_control_socket}" ] && cli_args="${cli_args} -s ${tor_control_socket}"
[ -n "${tor_password}" ] && cli_args="${cli_args} -p ${tor_password}"
[ -n "${machine_mode}" ] && cli_args="${cli_args} -m"
[ -n "${wait_confirmation}" ] && cli_args="${cli_args} -w"

#########
## Server

if [ -n "${list_onion}" ]; then
  # shellcheck disable=SC2086
  tor-ctrl ${cli_args} GETINFO onions/detached
  exit "${?}"
fi

if [ -n "${del_onion}" ]; then
  onion="$(validate_onion "${onion}")"
  # shellcheck disable=SC2086
  tor-ctrl ${cli_args} DEL_ONION "${onion}"
  exit "${?}"
fi

if [ -n "${add_onion}" ]; then
  : "${key:="NEW:BEST"}"
  [ -n "${port}" ] && port="Port=${port}"
  [ -z "${wait_confirmation}" ] && flags="${flags}Detach,"
  [ -n "${discard_pk}" ] && flags="${flags}DiscardPK,"
  [ -n "${client_pub_key}" ] && flags="${flags}V3Auth," client_pub_key="ClientAuthV3=${client_pub_key}"
  [ -n "${non_anonymous}" ] && flags="${flags}NonAnonymous,"
  [ -n "${max_streams}" ] && flags="${flags}MaxStreamsCloseCircuit," max_streams="MaxStreams=${max_streams}"
  [ -n "${flags}" ] && flags="Flags=${flags}"
  if [ -n "${permanent_onion_save}" ] && [ -n "${discard_pk}" ]; then
    error_msg "Permanent onion mode is not compatible with the flag that discards the private key."
  fi
  if [ -n "${permanent_onion_save}" ]; then
    test -d "${permanent_onion_save}" || error_msg "Permanent directory to save onion does not exist: ${permanent_onion_save}"
    trap "exit_handler" INT EXIT
    printf '%s\n' "${port} ${client_pub_key} ${max_streams} ${flags}" | tr -s " " | tee -- "${permanent_onion_save%*/}/onion.tmp"
    # shellcheck disable=SC2086
    tor-ctrl ${cli_args} ADD_ONION "${key}" "${port}" ${client_pub_key} ${max_streams} ${flags} | grep -- "^250-" | tee -a -- "${permanent_onion_save%*/}/onion.tmp"
    exit_code="${?}"
    [ "${exit_code}" -ne 0 ] && error_msg "Failed to create onion"
    onion_id_file="$(grep -- "250-ServiceID=" "${permanent_onion_save}/onion.tmp" | sed "s/250-ServiceID=//" | tr -d "\\r")"
    onion_id_file="$(validate_onion "${onion_id_file}")"
    ## guard against directory traversal
    permanent_onion_save_realpath="$(realpath "${permanent_onion_save%*/}")"
    onion_id_file_realpath="$(realpath "${permanent_onion_save%*/}/${onion_id_file}")"
    if [ -z "${onion_id_file_realpath}" ]; then
      error_msg "Intended onion ID file path could not be resolved, possibly directory traversal attack attempt?"
    fi
    if [ -z "${permanent_onion_save_realpath}" ]; then
      error_msg "Permanent onion save directory path could not be resolved, directory deleted?"
    fi
    if ! [[ "${onion_id_file_realpath}" =~ ^"${permanent_onion_save_realpath}" ]]; then
      error_msg "Onion ID file would end up saved outside of permanent onion save directory, possible directory traversal attack attempt?"
    fi
    ## save file with its Service Identification, good practice to identify the service
    mv -- "${permanent_onion_save%*/}/onion.tmp" "${permanent_onion_save%*/}/${onion_id_file}"
    exit "${?}"
  elif [ -n "${permanent_onion_restore}" ]; then
    if test -f "${permanent_onion_restore}"; then
      restore_parameters="$(grep -E -- "Port=|Flags=|ClientAuthV3=|MaxStreams=" "${permanent_onion_restore}" | tr "\n" " ")"
      restore_key="$(grep -- "250-PrivateKey=" "${permanent_onion_restore}" | sed "s/250-PrivateKey=//")"
      # shellcheck disable=SC2086
      tor-ctrl ${cli_args} ADD_ONION ${restore_key} ${restore_parameters} | grep -- "^250-"
      exit "${?}"
    elif test -d "${permanent_onion_restore}"; then
      for file in "${permanent_onion_restore%*/}/"*; do
        restore_parameters="$(grep -E -- "Port=|Flags=|ClientAuthV3=|MaxStreams=" "${file}" | tr "\n" " ")"
        restore_key="$(grep -- "250-PrivateKey=" "${file}" | sed "s/250-PrivateKey=//")"
        # shellcheck disable=SC2086
        tor-ctrl ${cli_args} ADD_ONION ${restore_key} ${restore_parameters} | grep -- "^250-"
      done
      exit "${?}" ## yes, just the last try of exit code
    else
      error_msg "Permanent directory or file to restore onion does not exist: ${permanent_onion_restore}"
    fi
  else
    # shellcheck disable=SC2086
    tor-ctrl ${cli_args} ADD_ONION "${key}" "${port}" ${client_pub_key} ${max_streams} ${flags} | grep -v -- "^250 "
  fi
  exit "${?}"
fi


#########
## Client

if [ -n "${onion_client_auth_add}" ]; then
  [ -z "${onion}" ] && error_msg "This option requires an onion service to be specified, use with '-o address.onion'"
  onion="$(validate_onion "${onion}")"
  # shellcheck disable=SC2086
  tor-ctrl ${cli_args} ONION_CLIENT_AUTH_ADD "${onion}" "${onion_client_auth_add}"
  exit "${?}"
fi

if [ -n "${onion_client_auth_remove}" ]; then
  [ -z "${onion}" ] && error_msg "This option requires an onion service to be specified, use with '-o address.onion'"
  onion="$(validate_onion "${onion}")"
  # shellcheck disable=SC2086
  tor-ctrl ${cli_args} ONION_CLIENT_AUTH_REMOVE "${onion}"
  exit "${?}"
fi

if [ -n "${onion_client_auth_view}" ]; then
  [ -n "${onion}" ] && onion="$(validate_onion "${onion}")"
  # shellcheck disable=SC2086
  tor-ctrl ${cli_args} ONION_CLIENT_AUTH_VIEW ${onion}
  exit "${?}"
fi

######
## if reached here and no major option was specified, show help message as nothing was run
usage
