#!/bin/bash . /etc/shlib include common include parse include cmdline include config include cache include fn [[ "${BASH_SOURCE[0]}" != "${0}" ]] && SOURCED=true version=0.1 desc='Install backup' help="" docker:running-container-projects() { :cache: scope=session docker ps --format '{{.Label "com.docker.compose.project"}}' | sort | uniq } decorator._mangle_fn docker:running-container-projects ssh:mk-private-key() { local host="$1" service_name="$2" ( settmpdir VPS_TMPDIR ssh-keygen -t rsa -N "" -f "$VPS_TMPDIR/rsync_rsa" -C "$service_name@$host" >/dev/null cat "$VPS_TMPDIR/rsync_rsa" ) } mailcow:has-images-running() { local images images=$(docker ps --format '{{.Image}}' | sort | uniq) [[ $'\n'"$images" == *$'\n'"mailcow/"* ]] } mailcow:has-container-project-mentionning-mailcow() { local projects projects=$(docker:running-container-projects) || return 1 [[ $'\n'"$projects"$'\n' == *mailcow* ]] } mailcow:has-running-containers() { mailcow:has-images-running || mailcow:has-container-project-mentionning-mailcow } mailcow:get-root() { :cache: scope=session local dir for dir in {/opt{,/apps},/root}/mailcow-dockerized; do [ -d "$dir" ] || continue [ -r "$dir/mailcow.conf" ] || continue echo "$dir" return 0 done return 1 } decorator._mangle_fn mailcow:get-root compose:get-compose-yml() { :cache: scope=session local path [ -e "/etc/compose/local.conf" ] && . "/etc/compose/local.conf" path=${DEFAULT_COMPOSE_FILE:-/etc/compose/compose.yml} [ -e "$path" ] || return 1 echo "$path" } decorator._mangle_fn compose:get-compose-yml compose:has-container-project-myc() { local projects projects=$(docker:running-container-projects) || return 1 [[ $'\n'"$projects"$'\n' == *$'\n'"myc"$'\n'* ]] } type:is-mailcow() { mailcow:get-root >/dev/null || mailcow:has-running-containers } type:is-compose() { compose:get-compose-yml >/dev/null && compose:has-container-project-myc } vps:get-type() { local fn for fn in $(declare -F | cut -f 3 -d " " | egrep "^type:is-"); do "$fn" && { echo "${fn#type:is-}" return 0 } done return 1 } mirror-dir:sources() { :cache: scope=session if ! shyaml get-values default.sources < /etc/mirror-dir/config.yml; then err "Couldn't query 'default.sources' in '/etc/mirror-dir/config.yml'." return 1 fi } decorator._mangle_fn mirror-dir:sources mirror-dir:check-add() { local elt="$1" sources sources=$(mirror-dir:sources) || return 1 if [[ $'\n'"$sources"$'\n' == *$'\n'"$elt"$'\n'* ]]; then info "Volume $elt already in sources" else Elt "Adding directory $elt" sed -i "/sources:/a\ - \"${elt}\"" \ /etc/mirror-dir/config.yml Feedback || return 1 fi } mirror-dir:check-add-vol() { local elt="$1" mirror-dir:check-add "/var/lib/docker/volumes/*_${elt}-*/_data" } ## The first colon is to prevent auto-export of function from shlib : ; bash-bug-5() { { cat; } < <(e) >/dev/null; ! cat "$1"; } && bash-bug-5 <(e) 2>/dev/null && export BASH_BUG_5=1 && unset -f bash_bug_5 wrap() { local label="$1" code="$2" shift 2 export VERBOSE=1 interpreter=/bin/bash if [ -n "$BASH_BUG_5" ]; then ( settmpdir tmpdir fname=${label##*/} e "$code" > "$tmpdir/$fname" && chmod +x "$tmpdir/$fname" && Wrap -vsd "$label" -- "$interpreter" "$tmpdir/$fname" "$@" ) else Wrap -vsd "$label" -- "$interpreter" <(e "$code") "$@" fi } mailcow:install-backup() { local BACKUP_SERVER="$1" mailcow_root DOMAIN ## find installation mailcow_root=$(mailcow:get-root) || { err "Couldn't find a valid mailcow root directory." return 1 } ## check ok DOMAIN=$(cat "$mailcow_root/.env" | grep ^MAILCOW_HOSTNAME= | cut -f 2 -d =) || { err "Couldn't find MAILCOW_HOSTNAME in file \"$mailcow_root/.env\"." return 1 } MYSQL_ROOT_PASSWORD=$(cat "$mailcow_root/.env" | grep ^DBROOT= | cut -f 2 -d =) || { err "Couldn't find DBROOT in file \"$mailcow_root/.env\"." return 1 } MYSQL_CONTAINER=${MYSQL_CONTAINER:-mailcowdockerized_mysql-mailcow_1} container_id=$(docker ps -f name="$MYSQL_CONTAINER" --format "{{.ID}}") if [ -z "$container_id" ]; then err "Couldn't find docker container named '$MYSQL_CONTAINER'." return 1 fi export MYSQL_ROOT_PASSWORD export MYSQL_CONTAINER export BACKUP_SERVER export DOMAIN wrap "Install rsync-backup on host" " cd /srv/charm-store/rsync-backup bash ./hooks/install.d/60-install.sh " || return 1 wrap "Mysql dump install" " cd /srv/charm-store/mariadb bash ./hooks/install.d/60-backup.sh " || return 1 ## Using https://github.com/mailcow/mailcow-dockerized/blob/master/helper-scripts/backup_and_restore.sh for elt in "vmail{,-attachments-vol}" crypt redis rspamd postfix; do mirror-dir:check-add-vol "$elt" || return 1 done mirror-dir:check-add "$mailcow_root" || return 1 mirror-dir:check-add "/var/backups/mysql" || return 1 mirror-dir:check-add "/etc" || return 1 dest="$BACKUP_SERVER" dest="${dest%/*}" ssh_options=() if [[ "$dest" == *":"* ]]; then port="${dest##*:}" dest="${dest%%:*}" ssh_options=(-p "$port") else port="" dest="${dest%%:*}" fi info "You can run this following from an host having admin access to $dest:" echo "ssh ${ssh_options[@]} myadmin@$dest ssh-key add '$(cat /var/lib/rsync/.ssh/id_rsa.pub)'" } compose:has_domain() { local compose_file="$1" host="$2" name conf relation relation_value domain server_aliases while read-0 name conf ; do name=$(e "$name" | shyaml get-value) if [[ "$name" =~ ^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+ ]]; then [ "$host" == "$name" ] && return 0 fi rel=$(e "$conf" | shyaml -y get-value relations 2>/dev/null) || continue for relation in web-proxy publish-dir; do relation_value=$(e "$rel" | shyaml -y get-value "$relation" 2>/dev/null) || continue while read-0 label conf_relation; do domain=$(e "$conf_relation" | shyaml get-value "domain" 2>/dev/null) && { [ "$host" == "$domain" ] && return 0 } server_aliases=$(e "$conf_relation" | shyaml get-values "server-aliases" 2>/dev/null) && { [[ $'\n'"$server_aliases" == *$'\n'"$host"$'\n'* ]] && return 0 } done < <(e "$relation_value" | shyaml -y key-values-0) done done < <(shyaml -y key-values-0 < "$compose_file") return 1 } compose:install-backup() { local BACKUP_SERVER="$1" service_name="$2" compose_file="$3" ignore_ping_check="$4" ignore_domain_check="$5" ## XXXvlab: far from perfect as it mimics and depends internal ## logic of current default way to get a domain in compose-core host=$(hostname) if ! compose:has_domain "$compose_file" "$host"; then if [ -n "$ignore_domain_check" ]; then warn "domain of '$host' not found in compose file '$compose_file'. Ignoring due to ``--ignore-domain-check`` option." else err "domain of '$host' not found in compose file '$compose_file'. Use ``--ignore-domain-check`` to ignore check." return 1 fi fi ip=$(getent ahosts "$host" | egrep "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s+" | head -n 1 | cut -f 1 -d " ") || return 1 my_ip=$(curl -s myip.kal.fr) if [ "$ip" != "$my_ip" ]; then if [ -n "$ignore_ping_check" ]; then warn "IP of '$host' ($ip) doesn't match mine ($my_ip). Ignoring due to ``--ignore-ping-check`` option." else err "IP of '$host' ($ip) doesn't match mine ($my_ip). Use ``--ignore-ping-check`` to ignore check." return 1 fi fi if [ -e "/root/.ssh/rsync_rsa" ]; then warn "deleting private key in /root/.ssh/rsync_rsa, has we are not using it anymore." rm -fv /root/.ssh/rsync_rsa fi if [ -e "/root/.ssh/rsync_rsa.pub" ]; then warn "deleting public key in /root/.ssh/rsync_rsa.pub, has we are not using it anymore." rm -fv /root/.ssh/rsync_rsa.pub fi if service_cfg=$(cat "$compose_file" | shyaml get-value -y "$service_name" 2>/dev/null); then info "Entry for service ${DARKYELLOW}$service_name${NORMAL}" \ "is already present in '$compose_file'." cfg=$(e "$service_cfg" | shyaml get-value -y options) || { err "No ${WHITE}options${NORMAL} in ${DARKYELLOW}$service_name${NORMAL}'s" \ "entry in '$compose_file'." return 1 } private_key=$(e "$cfg" | shyaml get-value private-key) target=$(e "$cfg" | shyaml get-value target) if [ "$target" != "$BACKUP_SERVER" ]; then err "Existing backup target '$target' is different" \ "from specified '$BACKUP_SERVER'" return 1 fi else private_key=$(ssh:mk-private-key "$host" "$service_name") cat <> "$compose_file" $service_name: options: ident: $host target: $BACKUP_SERVER private-key: | $(e "$private_key" | sed -r 's/^/ /g') EOF fi dest="$BACKUP_SERVER" dest="${dest%/*}" ssh_options=() if [[ "$dest" == *":"* ]]; then port="${dest##*:}" dest="${dest%%:*}" ssh_options=(-p "$port") else port="" dest="${dest%%:*}" fi info "You can run this following from an host having admin access to $dest:" public_key=$(ssh-keygen -y -f <(e "$private_key"$'\n')) echo "ssh ${ssh_options[@]} myadmin@$dest ssh-key add '$public_key ${service_name}@$host'" } [ "$SOURCED" ] && return 0 ## ## Command line processing ## cmdline.spec.gnu cmdline.spec.reporting cmdline.spec.gnu install cmdline.spec::cmd:install:run() { : } cmdline.spec.gnu get-type cmdline.spec::cmd:get-type:run() { vps:get-type } cmdline.spec:install:cmd:backup:run() { : :posarg: BACKUP_SERVER 'Target backup server' local vps_type vps_type=$(vps:get-type) || { err "Failed to get type of installation." return 1 } if ! fn.exists "${vps_type}:install-backup"; then err "type '${vps_type}' has no backup installation implemented yet." return 1 fi "cmdline.spec:install:cmd:$vps_type-backup:run" "$BACKUP_SERVER" } DEFAULT_BACKUP_SERVICE_NAME=rsync-backup cmdline.spec.gnu compose-backup cmdline.spec:install:cmd:compose-backup:run() { : :posarg: BACKUP_SERVER 'Target backup server' : :optval: --service-name,-s "YAML service name in compose file to check for existence of key. Defaults to '$DEFAULT_BACKUP_SERVICE_NAME'" : :optval: --compose-file,-f "Compose file location. Defaults to the value of '\$DEFAULT_COMPOSE_FILE'" : :optfla: --ignore-domain-check \ "Allow to bypass the domain check in compose file." : :optfla: --ignore-ping-check "Allow to bypass the ping check of host." local service_name compose_file [ -e "/etc/compose/local.conf" ] && source /etc/compose/local.conf compose_file=${opt_compose_file:-$DEFAULT_COMPOSE_FILE} service_name=${opt_service_name:-$DEFAULT_BACKUP_SERVICE_NAME} if ! [ -e "$compose_file" ]; then err "Compose file not found in '$compose_file'." return 1 fi compose:install-backup "$BACKUP_SERVER" "$service_name" "$compose_file" \ "$opt_ignore_ping_check" "$opt_ignore_domain_check" } cmdline.spec:install:cmd:mailcow-backup:run() { : :posarg: BACKUP_SERVER 'Target backup server' "mailcow:install-backup" "$BACKUP_SERVER" } cmdline.spec.gnu backup cmdline.spec::cmd:backup:run() { local vps_type vps_type=$(vps:get-type) || { err "Failed to get type of installation." return 1 } if ! fn.exists "cmdline.spec:backup:cmd:${vps_type}:run"; then err "type '${vps_type}' has no backup process implemented yet." return 1 fi "cmdline.spec:backup:cmd:${vps_type}:run" } cmdline.spec:backup:cmd:mailcow:run() { local cmd_line cron_line cmd for f in mysql-backup mirror-dir; do [ -e "/etc/cron.d/$f" ] || { err "Can't find '/etc/cron.d/$f'." echo " Have you forgotten to run 'vps install backup BACKUP_HOST' ?" >&2 return 1 } if ! cron_line=$(cat "/etc/cron.d/$f" | grep -v "^#" | grep "\* \* \*"); then err "Can't find cron_line in '/etc/cron.d/$f'." \ "Have you modified it ?" return 1 fi cron_line=${cron_line%|*} cmd_line=(${cron_line#*root}) if [ "$f" == "mirror-dir" ]; then cmd=() for arg in "${cmd_line[@]}"; do [ "$arg" != "-q" ] && cmd+=("$arg") done else cmd=("${cmd_line[@]}") fi code="${cmd[*]}" echo "${WHITE}Launching:${NORMAL} ${code}" { { ( ## Some commands are using colors that are already ## set by this current program and will trickle ## down unwantedly ansi_color no eval "${code}" ) | sed -r "s/^/ ${GRAY}|${NORMAL} /g" set_errlvl "${PIPESTATUS[0]}" } 3>&1 1>&2 2>&3 | sed -r "s/^/ $DARKRED\!$NORMAL /g" set_errlvl "${PIPESTATUS[0]}" } 3>&1 1>&2 2>&3 if [ "$?" != "0" ]; then err "Failed." return 1 fi done info "Mysql backup and subsequent mirror-dir ${DARKGREEN}succeeded${NORMAL}." } set_errlvl() { return "${1:-1}"; } cmdline.spec:backup:cmd:compose:run() { local cron_line args if ! cron_line=$(docker exec myc_cron_1 cat /etc/cron.d/rsync-backup | grep "\* \* \*"); then err "Can't find cron_line in cron container." echo " Have you forgotten to run 'compose up' ?" >&2 exit 1 fi cron_line=${cron_line%|*} cron_line=${cron_line%"2>&1"*} cmd_line="${cron_line#*root}" eval "args=($cmd_line)" ## should be last argument docker_cmd=$(echo ${args[@]: -1}) if ! [[ "$docker_cmd" == "docker run --rm -e "* ]]; then echo "docker command found should start with 'docker run'." >&2 echo "Here's command:" >&2 echo " $docker_cmd" >&2 exit 1 fi echo "${WHITE}Launching:${NORMAL} docker exec -i myc_cron_1 $docker_cmd" { { eval "docker exec -i myc_cron_1 $docker_cmd" | sed -r "s/^/ ${GRAY}|${NORMAL} /g" set_errlvl "${PIPESTATUS[0]}" } 3>&1 1>&2 2>&3 | sed -r "s/^/ $DARKRED\!$NORMAL /g" set_errlvl "${PIPESTATUS[0]}" } 3>&1 1>&2 2>&3 if [ "$?" != "0" ]; then err "Failed." return 1 fi info "mirror-dir ${DARKGREEN}succeeded${NORMAL}." } cmdline::parse "$@"