#! /bin/bash

# Helper to tunnel rsync through ssl on the client side.
# Example:
#   $ RSYNC_SSL_METHOD=socat rsync -e ./rsync-ssl-tunnel syncproxy2.eu.debian.org::
#   debian          Full Debian FTP Archive.
#   debian-debug    Debug packages.

# Copyright (c) 2016 Peter Palfrader
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.

set -e
set -u

usage() {
  echo "Usage: [RSYNC_SSL_CAPATH=<capath>] [RSYNC_SSL_PORT=<port>] [RSYNC_SSL_METHOD=stunnel4|stunnel4-old|socat] $0 <RSYNC_HOST>"
}

while [[ "$#" -gt 0 ]]; do
  case "$1" in
    -h|--help)
      usage
      exit 0
      ;;
    -l)
      shift
      shift
      continue
      ;;
    --)
      shift
      continue
      ;;
    -*)
      usage >&2
      exit 1
      ;;
    *)
      break
  esac
done

if [[ "$#" = 0 ]]; then
  usage >&2
  echo >&2 "No arguments given."
  exit 1
fi
RSYNC_HOST="$1"; shift
RSYNC_SSL_PORT=${RSYNC_SSL_PORT:-"1873"}
RSYNC_SSL_CAPATH=${RSYNC_SSL_CAPATH:-"/etc/ssl/certs"}
RSYNC_SSL_METHOD=${RSYNC_SSL_METHOD:-"stunnel4"}

method_stunnel() {
  skip_host_check="$1"; shift

  tmp="`tempfile`"
  trap "rm -f '$tmp'" EXIT

  (
    cat << EOF
# This file has been automatically created by ftpsync for syncing
# from ${RSYNC_HOST}.
#
# To test if things works, try the following:
#    rsync -e 'stunnel4 <this config file>' \$RSYNC_USER@dummy::
#
client = yes
verify = 2
CApath = ${RSYNC_SSL_CAPATH}

syslog = no
debug = 4
output = /dev/stderr

connect = ${RSYNC_HOST}:${RSYNC_SSL_PORT}
EOF
    if ! [ "$skip_host_check" = 1 ]; then
      echo "checkHost = ${RSYNC_HOST}"
    fi
  ) > "$tmp"

  exec stunnel4 "$tmp"
  echo >&2 "Failed to exec stunnel4"
  exit 1
}

method_socat() {
  exec socat - "openssl-connect:${RSYNC_HOST}:${RSYNC_SSL_PORT},capath=${RSYNC_SSL_CAPATH},keepalive,keepidle=300"
  echo >&2 "Failed to exec socat."
  exit 1
}

case ${RSYNC_SSL_METHOD:-} in
  stunnel4)
    method_stunnel 0
    ;;
  stunnel4-old)
    method_stunnel 1
    ;;
  socat)
    method_socat
    ;;
  *)
    echo >&2 "Unknown method $RSYNC_SSL_METHOD."
    exit 1
    ;;
esac
# -*- mode:sh -*-
# vim:syn=sh
# Little common functions

# push a mirror attached to us.
# Arguments (using an array named SIGNAL_OPTS):
#
# $MIRROR      - Name for the mirror, also basename for the logfile
# $HOSTNAME    - Hostname to push to
# $USERNAME    - Username there
# $SSHPROTO    - Protocol version, either 1 or 2.
# $SSHKEY      - the ssh private key file to use for this push
# $SSHOPTS     - any other option ssh accepts, passed blindly, be careful
# $PUSHLOCKOWN - own lockfile name to touch after stage1 in pushtype=staged
# $PUSHTYPE    - what kind of push should be done?
#                all    - normal, just push once with ssh backgrounded and finish
#                staged - staged. first push stage1, then wait for $PUSHLOCKs to appear,
#                         then push stage2
# $PUSHARCHIVE - what archive to sync? (Multiple mirrors behind one ssh key!)
# $PUSHCB      - do we want a callback?
# $PUSHKIND    - whats going on? are we doing mhop push or already stage2?
# $FROMFTPSYNC - set to true if we run from within ftpsync.
#
# This function assumes that the variable LOG is set to a directory where
# logfiles can be written to.
# Additionally $PUSHLOCKS has to be defined as a set of space delimited strings
# (list of "lock"files) to wait for if you want pushtype=staged
#
# Pushes might be done in background (for type all).
signal () {
    ARGS="SIGNAL_OPTS[*]"
    local ${!ARGS}

    MIRROR=${MIRROR:-""}
    HOSTNAME=${HOSTNAME:-""}
    USERNAME=${USERNAME:-""}
    SSHPROTO=${SSHPROTO:-""}
    SSHKEY=${SSHKEY:-""}
    SSHOPTS=${SSHOPTS:-""}
    PUSHLOCKOWN=${PUSHLOCKOWN:-""}
    PUSHTYPE=${PUSHTYPE:-"all"}
    PUSHARCHIVE=${PUSHARCHIVE:-""}
    PUSHCB=${PUSHCB:-""}
    PUSHKIND=${PUSHKIND:-"all"}
    FROMFTPSYNC=${FROMFTPSYNC:-"false"}

    # And now get # back to space...
    SSHOPTS=${SSHOPTS/\#/ }

    # Defaults we always want, no matter what
    SSH_OPTIONS="-o user=${USERNAME} -o BatchMode=yes -o ServerAliveInterval=45 -o ConnectTimeout=45 -o PasswordAuthentication=no"

    # If there are userdefined ssh options, add them.
    if [[ -n ${SSH_OPTS} ]]; then
        SSH_OPTIONS="${SSH_OPTIONS} ${SSH_OPTS}"
    fi

    # Does this machine need a special key?
    if [[ -n ${SSHKEY} ]]; then
        SSH_OPTIONS="${SSH_OPTIONS} -i ${SSHKEY}"
    fi

    # Does this machine have an extra own set of ssh options?
    if [[ -n ${SSHOPTS} ]]; then
        SSH_OPTIONS="${SSH_OPTIONS} ${SSHOPTS}"
    fi

    # Set the protocol version
    if [[ ${SSHPROTO} -ne 1 ]] && [[ ${SSHPROTO} -ne 2 ]] && [[ ${SSHPROTO} -ne 99 ]]; then
        # Idiots, we only want 1 or 2. Cant decide? Lets force 2.
        SSHPROTO=2
    fi

    if [[ -n ${SSHPROTO} ]] && [[ ${SSHPROTO} -ne 99 ]]; then
        SSH_OPTIONS="${SSH_OPTIONS} -${SSHPROTO}"
    fi

    date -u >> "${LOGDIR}/${MIRROR}.log"

    PUSHARGS=""
    # PUSHARCHIVE empty or not, we always add the sync:archive: command to transfer.
    # Otherwise, if nothing else is added, ssh -f would not work ("no command to execute")
    # But ftpsync does treat "sync:archive:" as the main archive, so this works nicely.
    PUSHARGS="${PUSHARGS} sync:archive:${PUSHARCHIVE}"

    # We have a callback wish, tell downstreams
    if [[ -n ${PUSHCB} ]]; then
        PUSHARGS="${PUSHARGS} sync:callback"
    fi
    # If we are running an mhop push AND our downstream is one to receive it, tell it.
    if [[ mhop = ${PUSHKIND} ]] && [[ mhop = ${PUSHTYPE} ]]; then
        PUSHARGS="${PUSHARGS} sync:mhop"
    fi

    if [[ all = ${PUSHTYPE} ]]; then
        # Default normal "fire and forget" push. We background that, we do not care about the mirrors doings
        log "Sending normal push" >> "${LOGDIR}/${MIRROR}.log"
        PUSHARGS1="sync:all"
        ssh -n $SSH_OPTIONS "${HOSTNAME}" "${PUSHARGS} ${PUSHARGS1}" >>"${LOGDIR}/${MIRROR}.log" 2>&1
        if [[ $? -eq 255 ]]; then
            error "Trigger to ${HOSTNAME} failed"  >> "${LOG}"
        else
            log "Trigger to ${HOSTNAME} succeed" >> "${LOG}"
        fi
    elif [[ staged = ${PUSHTYPE} ]] || [[ mhop = ${PUSHTYPE} ]]; then
        # Want a staged push. Fine, lets do that. Not backgrounded. We care about the mirrors doings.
        log "Sending staged push" >> "${LOGDIR}/${MIRROR}.log"

        # Only send stage1 if we havent already send it. When called with stage2, we already did.
        if [[ stage2 != ${PUSHKIND} ]]; then
            # Step1: Do a push to only sync stage1, do not background
            PUSHARGS1="sync:stage1"
            ssh $SSH_OPTIONS "${HOSTNAME}" "${PUSHARGS} ${PUSHARGS1}" >>"${LOGDIR}/${MIRROR}.log" 2>&1
            if [[ $? -eq 255 ]]; then
                error "Trigger to ${HOSTNAME} failed"  >> "${LOG}"
            else
                log "Trigger to ${HOSTNAME} succeed" >> "${LOG}"
            fi
            touch "${PUSHLOCKOWN}"

            # Step2: Wait for all the other "lock"files to appear.
            tries=0
            # We do not wait forever
            while [[ ${tries} -lt ${PUSHDELAY} ]]; do
                total=0
                found=0
                for file in ${PUSHLOCKS}; do
                    total=$(( total + 1 ))
                    if [[ -f ${file} ]]; then
                        found=$(( found + 1 ))
                    fi
                done
                if [[ ${total} -eq ${found} ]] || [[ -f ${LOCKDIR}/all_stage1 ]]; then
                    touch "${LOCKDIR}/all_stage1"
                    break
                fi
                tries=$(( tries + 5 ))
                sleep 5
            done
            # In case we did not have all PUSHLOCKS and still continued, note it
            # This is a little racy, especially if the other parts decide to do this
            # at the same time, but it wont hurt more than a mail too much, so I don't care much
            if [[ ${tries} -ge ${PUSHDELAY} ]]; then
                log "Failed to wait for all other mirrors. Failed ones are:" >> "${LOGDIR}/${MIRROR}.log"
                for file in ${PUSHLOCKS}; do
                    if [[ ! -f ${file} ]]; then
                        log "${file}" >> "${LOGDIR}/${MIRROR}.log"
                        error "Missing Pushlockfile ${file} after waiting ${tries} second, continuing"
                    fi
                done
            fi
            rm -f "${PUSHLOCKOWN}"
        fi

        # Step3: It either timed out or we have all the "lock"files, do the rest
        # If we are doing mhop AND are called from ftpsync - we now exit.
        # That way we notify our uplink that we and all our clients are done with their
        # stage1. It can then finish its own, and if all our upstreams downlinks are done,
        # it will send us stage2.
        # If we are not doing mhop or are not called from ftpsync, we start stage2
        if [[ true = ${FROMFTPSYNC} ]] && [[ mhop = ${PUSHKIND} ]]; then
            return
        else
            PUSHARGS2="sync:stage2"
            log "Now doing the second stage push" >> "${LOGDIR}/${MIRROR}.log"
            ssh $SSH_OPTIONS "${HOSTNAME}" "${PUSHARGS} ${PUSHARGS2}" >>"${LOGDIR}/${MIRROR}.log" 2>&1
            if [[ $? -eq 255 ]]; then
                error "Trigger to ${HOSTNAME} failed"  >> "${LOG}"
            else
                log "Trigger to ${HOSTNAME} succeed" >> "${LOG}"
            fi
        fi
    else
        # Can't decide? Then you get nothing.
        return
    fi
}

# callback, used by ftpsync
callback () {
    # Defaults we always want, no matter what
    SSH_OPTIONS="-o BatchMode=yes -o ServerAliveInterval=45 -o ConnectTimeout=45 -o PasswordAuthentication=no"
    ssh $SSH_OPTIONS -i "$3" -o"user $1" "$2" callback:${HOSTNAME}
}

# log something (basically echo it together with a timestamp)
#
# Set $PROGRAM to a string to have it added to the output.
log () {
    if [[ -z "${PROGRAM}" ]]; then
        echo "$(date +"%b %d %H:%M:%S") $(hostname -s) [$$] $@"
    else
        echo "$(date +"%b %d %H:%M:%S") $(hostname -s) ${PROGRAM}[$$]: $@"
    fi
}

# log the message using log() but then also send a mail
# to the address configured in MAILTO (if non-empty)
error () {
    log "$@"
    if [[ -n "${MAILTO}" ]]; then
        echo "$@" | mail -e -s "[$PROGRAM@$(hostname -s)] ERROR [$$]" ${MAILTO}
    fi
}

# run a hook
# needs array variable HOOK setup with HOOKNR being a number an HOOKSCR
# the script to run.
hook () {
    ARGS='HOOK[@]'
    local "${!ARGS}"
    if [[ -n ${HOOKSCR} ]]; then
        log "Running hook $HOOKNR: ${HOOKSCR}"
        set +e
        ${HOOKSCR}
        result=$?
        set -e
        if [[ ${result} -ne 0 ]] ; then
            error "Back from hook $HOOKNR, got returncode ${result}"
        else
            log "Back from hook $HOOKNR, got returncode ${result}"
        fi
        return $result
    else
        return 0
    fi
}

# Return the list of 2-stage mirrors.
get2stage() {
    egrep '^(staged|mhop)' "${MIRRORS}" | {
        while read MTYPE MLNAME MHOSTNAME MUSER MPROTO MKEYFILE; do
            PUSHLOCKS="${LOCKDIR}/${MLNAME}.stage1 ${PUSHLOCKS}"
        done
        echo "$PUSHLOCKS"
    }
}

# Rotate logfiles
savelog() {
    torotate="$1"
    count=${2:-${LOGROTATE}}
    while [[ ${count} -gt 0 ]]; do
        prev=$(( count - 1 ))
        if [[ -e ${torotate}.${prev} ]]; then
            mv "${torotate}.${prev}" "${torotate}.${count}"
        fi
        count=$prev
    done
    if [[ -e ${torotate} ]]; then
        mv "${torotate}" "${torotate}.0"
    fi
}

# Return rsync version
rsync_protocol() {
    RSYNC_VERSION="$(${RSYNC} --version)"
    RSYNC_REGEX="(protocol[ ]+version[ ]+([0-9]+))"    
    if [[ ${RSYNC_VERSION} =~ ${RSYNC_REGEX} ]]; then
        echo ${BASH_REMATCH[2]}
    fi
    unset RSYNC_VERSION RSYNC_REGEX
}

# Search config files in various locations
search_config() {
  local file
  if [ "$BASEDIR" ]; then
    file="$BASEDIR/etc/$1"
    if [ -f "$file" ]; then
      echo "$file"
      return
    fi
  fi
  for i in ~/.config/archvsync /etc/archvsync; do
    file="$i/$1"
    if [ -f "$file" ]; then
      echo "$file"
      return
    fi
  done
}

# Read config file
read_config() {
  local config=$(search_config "$1")
  if [ "$config" ]; then
    . "$config"
    return 0
  fi
  return 1
}
