#!/bin/bash
# ----------------------------------------------
# Update Sympl sites with DNS hosting at Mythic
# Beasts. Use M-B Primary DNS API, v1 (legacy).
# Writes octoDNS config file and runs octodns-sync
#
# https://forum.sympl.io/
# https://www.mythic-beasts.com/support/api/dns
#
# v0.1 2024-12-07 first in-the-wild release ;)
# ----------------------------------------------
version=0.1

function set_colours () {
    cyan='\e[1;36m'
    green='\e[1;32m'
    red='\e[91m'
    white='\e[97m'
    on_black='\e[40m'
    on_red='\e[1;41m'
    end='\e[0m'
    cyan_black="$cyan$on_black"
    green_black="$green$on_black"
    red_black="$red$on_black"
	red_white="$red$on_white"
	white_red="$white$on_red"
}

function display_help () {
    read -d '' help_text << EOF
    Update DNS for Sympl sites with primary DNS hosting
    at Mythic Beasts using legacy DNS API, v1.

    Requires
    - python virtual environment with modules;
      octodns==1.10.0 & octodns-mythicbeasts==0.0.1
    - ./octoyaml.py >= 0.1
    - API password file: ./mythicbeasts.keys

    usage: $(echo -e "${green_black}dns-sync${end} $cyan_b[OPTIONS]${end} ${green_black}[zones]${end}")

    $(echo -e "${cyan_black}--config-file=${end}")     octoDNS config file name path.
                       Re-written on each run.
                       Default: ./mythicbeasts.yaml

    $(echo -e "${cyan_black}--debug${end}")            Invoke debug level logging and
                       set 'api-debug' TXT record.
                       Default: not set

    $(echo -e "${cyan_black}--doit${end}")             Live run. Unless set, octoDNS
                       runs a simulation, only showing
                       planned updates.
                       Default: not set

    $(echo -e "${cyan_black}--mono${end}")             Don't colourize dns-sync console
                       output.
                       Default: not set

    $(echo -e "${cyan_black}--venv=${end}")            Full path to the Python virtual
                       environment directory.
                       Default: /home/sympl/venv

    $(echo -e "${green_black}[zone1 zone2... ]${end}")  Restrict octoDNS processing to
                       the named domains or subdomains.
                       Names can end in ".";

                       $(basename $0) 4.example.com. --debug

    File paths with whitespace or special characters
    require quoting;

    --config-file=$(echo -e "${cyan_black}\"${end}./test 22,202 & counting.yaml${cyan_black}\"${end}")

    $(basename $0) v${version}
EOF
    echo -e "\n    ${help_text}\n"
}

function log () {
    # $1 message, $2 '!+' paints urgency
    local colour="${green_black}"
    local level='INFO'
    if [ "$2" = '!' ]; then
        # colour="${white}${on_grey}"
        colour="${red_black}"
        level='WARNING'
    elif [ "$2" = '!!' ]; then
        colour="${white_red}"
        level='ERROR'
    fi
    local was=$(date +'%Y-%m-%d %T')
    echo -e "$was ${colour}dns-sync${end}: $level - $1"
}

function abort () {
    # exit with attempted grace
    # $1 - error message
    log "Aborting! $1" '!!'
    set_owner_sympl
    exit 1
}

function zone_name_check () {
    # test validity of zone name ($1)
    # return 0 if OK, 1 if not
    local zone="$1"
    local zone_length=${#zone}
    # dot-count and zone-length include the trailing "."
    local min_dot_count=2    # (example.com.)
    local min_zone_len=5     # (x.io.)
    local dot_count=$(echo "$zone" | grep -o '\.' | wc -l)
    if [ "$dot_count" -lt $min_dot_count ] || [ $zone_length -lt $min_zone_len ]; then
        log "zone: '$z' - rejecting; too short (<$min_zone_len) or too few parts (expected at least $min_dot_count dots)" '!'
        return 1
    fi
    # invalid characters
    for ((i = 0; i < $zone_length; i++)); do
        local char="${z:i:1}"
        if ! [[ "$char" =~ [.a-zA-Z0-9\-] ]]; then
            log "zone: '$z' - rejecting due to invalid character; $char" '!'
            return 1
        fi
    done
    # invalid placement
    if [[ "$zone" == *.-* ]] || [[ "$zone" == *-.* ]]; then
        log "zone: '$z' - rejecting due to invalid hyphen position" '!'
        return 1
    fi
    # skip label length checks
    # finally, a quick look for the beggar in the config-file
    case $(grep -iF " $zone:" "$config_file" &>/dev/null; echo $?) in
      0) log "zone: $zone - accepted for octo-processing"
         ;;
      1) log "zone: '$zone' - ignoring unrecognised zone (not found in $config_file)" '!'
         return 1
         ;;
      *) log "zone: '$zone' - An error occurred trying to grep '$config_file'" '!'
         return 1
         ;;
    esac

    return 0
}

function read_yaml_key () {
    # return value of yaml file ($1) key ($2) - or "[indeterminate]"
    "$venv_dir"/bin/python -c "import yaml;print(yaml.safe_load(open('$1'))$2)" 2>/dev/null || local exitval=$?
    [ "$exitval" ] && echo "[indeterminate]" # key or other error
    return 0
}

function set_owner_sympl () {
    # Change file owner, if able & necessary
    if [ "$EUID" -eq 0 ]; then
        local files=( errors.log )
        [ "$octodns_log" ] && files+=( "$octodns_log" )
        for fp in "$files"
        do
            if [ -e "$fp" ] && [ -O "$fp" ]; then
                log "logging: changing '$fp' owner to sympl"
                chown sympl "$fp"
            fi
        done
    fi
}

# Command line options -- reject unknowns options,
# save the rest as possible zones.
zones=()
options_not=()
while [ $# -gt 0 ]; do
case $1 in
    --help|-h)        help=true              ; shift ;;
    --config-file=*)  config_file="${1:14}"  ; shift ;;
    --doit)           dry_run=false          ; shift ;;
    --debug|-d)       debug=true             ; shift ;;
    --octoyaml_log=*) octoyaml_log="${1:15}" ; shift ;; # undocumented
    --mono)           colourize=false        ; shift ;;
    --venv=*)         venv_dir="${1:7}"      ; shift ;;
    -*)               options_not+=("$1")    ; shift ;;
    *\.)              zones+=("$1")          ; shift ;;
    *)                zones+=("$1.")         ; shift ;; # dot the undotted
esac
done

# colourize console output unless --mono
! [ "$colourize" ] && set_colours

# unrecognised option, alert & abort if it's a live run
if [ "$options_not" ]; then
    for o_not in "${options_not[@]}"
    do
        log "command line: '$o_not' - ignoring unrecognised option" '!'
    done
    [ "$dry_run" = 'false' ] && log "Aborting! Live run with unknown options. '--help' might help ;)..." '!!' && help=true
fi
# help those who ask, plus live option_notters
[ "$help" ] && display_help && exit 0

# Prepare for retrospective trouble ;)
# (if sudo created files - and crashed -
#  non-sudo wont be able to write to them)
set_owner_sympl

# By default, keep all outputs with this script
script_dir=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd 2>> errors.log ) || sd_rc=$?
if [ "$sd_rc" ]; then
    echo -e "\ndns-sync: script_dir error occurred at $(date +'%Y-%m-%d %T')\n" >> errors.log
    abort "! Aborting. Couldn't determine the dns-sync working directory (exit code $sd_rc). See errors.log"
fi
# The octodns-logging-config file might set a relative
# path for its log file. To stop it unintentionally
# landing in /root/BytemarkDNS...
cd $script_dir

# octoDNS config file to be written by octoyaml.py
if ! [ "$config_file" ]; then
    config_file="$script_dir/mythicbeasts.yaml"
else
    [[ $config_file == /* ]] || config_file="$script_dir/$config_file"
fi

# Command line - common to octoyaml & octosync
args_octoyml=( --config-file="$config_file" )
[ "$debug" = 'true' ] && args_octoyml+=( --debug )
args_octodns=$args_octoyml

# Rev up python virtual environment
# ---------------------------------
[ "$venv_dir" ] || venv_dir=/home/sympl/venv
source "$venv_dir/bin/activate" 2>> errors.log || source_rc=$?
if [ "$source_rc" ]; then
    echo -e "\ndns-sync: source error shown above occurred at $(date +'%Y-%m-%d %T')\n" >> errors.log
    abort "Error (return code $source_rc) with source '$venv_dir/bin/activate'. See errors.log"
fi

# octoyaml.py
# ------------------
# log file
if [ "$octoyaml_log" ]; then
    [[ "$octoyaml_log" != /* ]] || octoyaml_log="$script_dir/$octoyaml_log"
    args_octoyml+=( --log-file="$octoyaml_log" )
fi

log "calling octoyaml.py..."
# execute
"$script_dir"/octoyaml.py "${args_octoyml[@]}" 2>> errors.log || oyml_rc=$?
if [ "$oyml_rc" ]; then
    echo -e "\ndns-sync: octoyaml.py error shown above occurred at $(date +'%Y-%m-%d %T')\n" >> errors.log
    abort "octoyaml.py non-zero exit code ($oyml_rc). See errors.log"
fi

# octoDNS
# ------------------
# --force to by-pass percent-change safety threshold
# --log-stream-stdout to prevent shell 2> redirects receiving stdout + stderr
#   https://github.com/octodns/octodns/issues/1228
args_octodns+=( --force --log-stream-stdout )
# simulation or real thing
[ "$dry_run" = 'false' ] && args_octodns+=( --doit )
# logging config file
if [ "$debug" = 'true' ]; then
    octodns_log_cfg="$script_dir/octodns-logging-config=debug.yaml"
else
    octodns_log_cfg="$script_dir/octodns-logging-config.yaml"
fi
# We need the name of the logfile set in the logging-config file
# so that we can 1) check write access 2) change permissions,
# if necessary, ensuring that non-superusers can write to the file.
# So, if the octoDNS logging config file exists and we think it has
# a writable target, use it, else ignore the config file (yes, harsh)
if [ -f "$octodns_log_cfg" ]; then
    octodns_log=$(read_yaml_key "$octodns_log_cfg" "['handlers']['file']['filename']")
    if [ "$octodns_log" = '[indeterminate]' ]; then
        log "logging: ignoring config file; unable to determine log filename in $octodns_log_cfg" '!'
        unset octodns_log
    elif [ "$octodns_log" ] && [ ! -e "$octodns_log" ] || [ -w "$octodns_log" ]; then
        args_octodns+=( --logging-config="$octodns_log_cfg" )
        log "logging: $octodns_log_cfg target '$octodns_log' looks good"
    else
        log "logging: ignoring config file; can't write to '$octodns_log' set in $octodns_log_cfg" '!'
    fi
fi
# Clean zone list
zones_valid=()
for z in "${zones[@]}"
do
    zone_name_check "$z" && [ $? = 0 ] && zones_valid+=("$z")
done
args_octodns+=( "${zones_valid[@]}" )
case "${#zones_valid[@]}" in
    0) zone_scope='all zones' ;;
    1) zone_scope='1 zone' ;;
    *) zone_scope="${#zones_valid[@]} zones" ;;
esac
# execute
log "calling octoDNS for $zone_scope..."
octodns-sync "${args_octodns[@]}" 2>> errors.log || osync_rc=$?
if [ "$osync_rc" ]; then
    log "Hmm, octoDNS return code non-zero ($osync_rc). See: errors.log $octodns_log" '!!'
    auth_err=$( tail -n1 errors.log | grep -Poha "'Mythic Beasts unauthorized for zone: \K([a-z\-0-9.]+)" 2> /dev/null )
    echo -e "\ndns-sync: octodns-sync error shown above occurred at $(date +'%Y-%m-%d %T')\n" >> errors.log
    [ $auth_err ] && log "Check API key/rights for $auth_err" '!!'
fi

# Update output files owner (octoyaml.py manages octoyaml.log)
set_owner_sympl
log 'Over and out.'
echo
exit 0