#!/usr/bin/bash #---------------------------------------------------------------------- # # #---------------------------------------------------------------------- CT_DIR=${CT_DIR:=/etc/pve/lxc/} # XXX setup path... # XXX EDITOR=${EDITOR:-nano} LAST_RUN_CONFIG=config.last-run #---------------------------------------------------------------------- # XXX this is quite generic, might be a good idea to move this to a # seporate lib/file... # Execute (optionally) and print a command. # # @ COMMAND ARGS # #QUIET= #DRY_RUN= ECHO_PREFIX="### " function @ (){ if [ -z $DRY_RUN ] ; then ! [ $QUIET ] \ && echo -e "${ECHO_PREFIX}$@" "$@" else echo -e $@ fi return $? } #---------------------------------------------------------------------- # # check MSG COMMAND .. # check(){ local MSG=$1 shift for cmd in "$@" ; do which $cmd > /dev/null 2>&1 \ || eval "echo \"$MSG\"" >&2 done } need(){ check 'ERROR: "$cmd": needed by this script but not in path.' "$@" } would-like(){ check 'WARNING: "$cmd": is not in path.' "$@" } #---------------------------------------------------------------------- # Fill section... # # XXX this is quite generic -- move to a more logical place... fillsection(){ ( usage(){ echo "Usage:" echo " ${FUNCNAME[0]} [-h]" echo " ${FUNCNAME[0]} [-r] NAME FILE [CONTENT]" echo } while true ; do case $1 in -h|--help) usage echo "Options:" # . . . echo " -h | --help print this help message and exit." echo " -r | --return replace section markers with CONTENT." echo return 0 ;; -r|--replace) local replace=1 shift ;; *) break ;; esac done if [[ $# < 2 ]] ; then usage return 1 fi name=$1 file=$2 content=$3 content=${content:=/dev/stdin} # print file upto section marker... if [ $replace ] ; then sed "/${name^^} BEGIN/q" "$file" | sed '$d' else sed "/${name^^} BEGIN/q" "$file" fi # print content... cat $content # print file from section end marker... if [ $replace ] ; then sed -ne "/${name^^} END/,$ p" "$file" | sed '1d' else sed -ne "/${name^^} END/,$ p" "$file" fi ) } #---------------------------------------------------------------------- # CT hostname <-> CT id... ct2hostname(){ local ct=${CT_DIR}/${1}.conf local host=$(cat $ct \ | grep -e '^\s*hostname:' \ | head -1) echo ${host/hostname: /} } hostname2ct(){ if [ -e "${CT_DIR}/${1}.conf" ] ; then echo $1 fi local running=$2 running=${running:=any} local ct local host for ct in "${CT_DIR}"/*.conf ; do host=$(cat $ct | grep hostname | head -1) host=${host/hostname: /} if [ "$host" = $1 ] ; then ct=${ct#${CT_DIR}} ct=${ct%.conf} ct=${ct#\/} # filter results if needed... if [ $running = "any" ] ; then echo $ct else local status=`pct status $ct` if [ "$running" = "${status/status: /}" ] ; then echo $ct fi fi fi done } #---------------------------------------------------------------------- normpath(){ echo $1 \ | sed \ -e 's/\/\+/\//g' \ -e 's/\/.\//\//g' \ -e 's/[^\/]\+\/\.\.//g' \ -e 's/\/\+/\//g' \ -e 's/\/\.$/\//g' } #---------------------------------------------------------------------- # # xread [-n] MSG VAR # # This saves all user input variables to the $XREAD_VARS array. xread(){ local non_empty= if [[ $1 == '-n' ]] ; then shift local non_empty=1 fi local prefix= if [ $SCRIPTING ] ; then prefix='# ' fi # skip... if [[ "${!2}" == "SKIP" ]] \ || [[ "$(eval "echo \$DFL_$2")" == "SKIP" ]] ; then eval "$2=" return fi if [ -z ${!2} ] ; then eval 'read -ep "'$prefix''$1'" -i "$DFL_'$2'" '${2}'' XREAD_VARS+=(${2}) fi if [ -z $non_empty ] ; then eval ''$2'=${'$2':=$DFL_'$2'}' fi [ $SCRIPTING ] \ && echo "$2=${!2}" } # # xreadYes MSG [VAR] # xreadYes(){ if [ -z ${2} ] ; then local var=__LOCAL local __LOCAL else local var=${2} local mode= fi local prefix= if [ $SCRIPTING ] ; then prefix='# ' fi # XXX check DFL_..??? if [[ "${!var}" == "SKIP" ]] ; then eval "$var=" return 1 fi if [ -z ${!var} ] ; then if [[ "$(eval "echo \$DFL_${var}")" == "SKIP" ]] ; then eval "$var=" return 1 elif [ -z $(eval "echo \$DFL_${var}") ] ; then local yes=y local no=N local dfl= else local yes=Y local no=n local dfl=1 fi eval 'read -ep "'$prefix''$1' ('$yes'/'$no') " '${var}'' XREAD_VARS+=(${var}) # normalize... eval "${var}=${!var,,}" if [[ "${!var}" == 'y' ]] ; then eval "${var}=1" elif [[ ${!var} == 'n' ]] ; then eval "${var}=" # set default if empty... else eval "${var}=\${${var}:-$dfl}" fi fi [ $SCRIPTING ] \ && [[ "$var" != '__LOCAL' ]] \ && echo "$var=${!var}" if [ -z ${!var} ] ; then return 1 fi } # # xreadpass [msg] VAR # xreadpass(){ local msg if [[ $# == 2 ]] ; then msg="$1 " shift fi if [[ ${!1} == 'SKIP' ]] ; then return fi local prefix= if [ $SCRIPTING ] ; then prefix='# ' fi local PASS1 local PASS2 for attempt in 1 2 3 ; do read -sep "${prefix}${msg}password (Enter to skip): " PASS1 if [ -z $PASS1 ] ; then return fi read -sep "${prefix}retype password: " PASS2 if [[ $PASS1 != $PASS2 ]] ; then echo "ERROR: passwords do not match." >&2 continue fi eval ''$1'='${PASS1}'' return done return 1 } # Like cat but a prettier... # # listFile PATH # listFile(){ if [ -e "$1" ] ; then echo "--- $1 ---" cat "$1" echo '---' else echo "$FUNCNAME: $1: No such file or directory." return 1 fi } # Review changes in PATH.new, then edit/apply changes to PATH # # reviewApplyChanges PATH [apply|edit|skip] # # # NOTE: if changes are not applied this will return non-zero making this # usable in conditionals... reviewApplyChanges(){ local file=$1 if ! [ -e "$file".new ] ; then echo "$FUNCNAME: $1: No such file or directory." return 1 fi # default option... local dfl= local a=a local e=e local s=s case "${2,,}" in a|apply) a=A dfl=a ;; e|edit) e=E dfl=e ;; s|skip) s=S dfl=s ;; esac echo "# Review updated: ${file}.new:" listFile ${file}.new local res while true ; do read -ep "# [$a]pply, [$e]dit, [$s]kip? " res if [ -z $res ] ; then if [ -z $dfl ] ; then continue fi res=$dfl fi case "${res,,}" in a|apply) break ;; e|edit) ${EDITOR} "${file}.new" listFile ${file}.new ;; s|skip) echo "# Changes kept as: ${file}.new" return 1 ;; *) echo "ERROR: Unknown command: \"$res\"" >&2 continue ;; esac done @ mv -b "${file}"{.new,} } #---------------------------------------------------------------------- # # readCTConfig # # XXX list or load a specific CT config... readCTConfig(){ if [ -z $ID ] && ! [ -z $CTHOSTNAME ] ; then # XXX select by id... true elif ! [ -z $ID ] && [ -z $CTHOSTNAME ] ; then # XXX select by hostname... true else # XXX list all... return fi local ct_cfg=$ID-$CTHOSTNAME.cfg if [ -e $ct_cfg ] ; then source "$ct_cfg" fi } # # readConfig # # Envioronment variables: # CLEAN_RUN - if set ignore ./config.last-run # CONFIG - config file to load last # # XXX need this not to make this behave with explicitly set vars... readConfig(){ if [ -z $NO_DEFAULTS ] ; then local IFS=$'\n' #__ENV=($( (set -o posix ; set | grep -v 'BASHOPTS=') )) #__ENV=($( (declare -xp) )) [ -e ../config.global ] \ && source ../config.global [ -e ./config ] \ && source ./config # XXX is this the right priority for this??? [ -e ./$LAST_RUN_CONFIG ] \ && [ -z $CLEAN_RUN ] \ && source ./$LAST_RUN_CONFIG [ -e "$CONFIG" ] \ && source $CONFIG #eval "${__ENV[@]}" #__ENV= fi } # # saveConfig [-d|-a] CONFIG VAR .. # saveConfig(){ local prefix= local append= while true ; do case $1 in -d|--default) prefix=DFL_ shift ;; -a|--append) append=1 shift ;; *) break ;; esac done local cfg=$1 shift if [ -z $append ] ; then printf '' > "$cfg" fi { for var in $@ ; do echo "${prefix}${var}=${!var}" done echo } >> "$cfg" } saveLastRunConfig(){ local cfg=$LAST_RUN_CONFIG local ct_cfg if ! [ -z $ID ] && ! [ -z $CTHOSTNAME ] ; then local ct_cfg=$ID-$CTHOSTNAME.cfg fi echo "# Saving config to: $cfg ${ct_cfg:+and $ct_cfg}" { echo "#" echo "# This file is auto-generated, any changes here will be overwritten." echo "#" } > "$cfg" saveConfig -d -a "$cfg" ${XREAD_VARS[@]} if ! [ -z ${ct_cfg} ] ; then cp "$cfg" "$ct_cfg" fi } # # webAppConfig NAME # webAppConfig(){ local name=${1^^} eval "${name}_SUBDOMAIN=\${${name}_SUBDOMAIN:=\${DFL_SUB${name}_DOMAIN}} ${name}_SUBDOMAIN=\${${name}_SUBDOMAIN:+\${${name}_SUBDOMAIN%.}.} ${name}_DOMAIN=\${${name}_DOMAIN:=\${DFL_${name}_DOMAIN}} # prioretize \${name}_* DFL_DOMAIN=\${DFL_DOMAIN:+\${${name}_SUBDOMAIN}\${DFL_DOMAIN}} DFL_DOMAIN=\${DOMAIN:+\${${name}_SUBDOMAIN}\${DOMAIN}} if [ \$${name}_DOMAIN ] ; then DFL_DOMAIN=\${${name}_SUBDOMAIN}\${${name}_DOMAIN} fi" # force check of domain... DOMAIN= } # # readVars # readCTHardwareVars # readBridgeVars # # Variables this handles: # EMAIL # DOMAIN # ID # CTHOSTNAME # WAN_BRIDGE # LAN_BRIDGE # ADMIN_BRIDGE # WAN_IP # WAN_GATE # LAN_IP # LAN_GATE # ADMIN_IP # ADMIN_GATE # ROOTPASS # PCT_EXTRA # # Variables this sets: # PASS # # Variables used: # TMP_PASS_LEN # ROOTPASS # readCTVars(){ xread "ID: " ID xread "Hostname: " CTHOSTNAME # hardware... xread "CPU cores: " CORES xread "RAM (MB): " RAM xread "SWAP (MB): " SWAP xread "DRIVE (GB): " DRIVE } readBridgeVars(){ # bridge config... xread "ADMIN bridge: vmbr" ADMIN_BRIDGE xread "WAN bridge: vmbr" WAN_BRIDGE xread "LAN bridge: vmbr" LAN_BRIDGE } readVars(){ xread -n "Email: " EMAIL xread -n "Domain: " DOMAIN xread -n "Gate ID: " GATE_ID readCTVars readBridgeVars # gateway... # IPs can be: # # / # dhcp # Gateways can be: # # # XXX these are the same... xread "WAN ip: " WAN_IP if [[ $WAN_IP != "dhcp" ]] ; then xread "WAN gateway: " WAN_GATE else WAN_GATE= fi xread "LAN ip: " LAN_IP if [[ $LAN_IP != "dhcp" ]] ; then xread "LAN gateway: " LAN_GATE else LAN_GATE= fi xread "ADMIN ip: " ADMIN_IP if [[ $ADMIN_IP != "dhcp" ]] ; then xread "ADMIN gateway: " ADMIN_GATE else ADMIN_GATE= fi # root password... if [ -z $ROOTPASS ] ; then xreadpass root PASS \ || exit 1 else PASS=$ROOTPASS fi # extra stuff... xread "pct extra options: " PCT_EXTRA } # # makeTemplateSEDPatterns VAR ... # makeTemplateSEDPatterns(){ local var for var in "$@" ; do local val=${!var} if [[ $val == SKIP ]] ; then val= fi echo "-e 's/\\\${${var}}/${val//\//\\/}/g'" done } # same as makeTemplateSEDPatterns but adds default vars + generates *_IPn vars... PCT_TEMPLATE_VARS=( EMAIL DOMAIN CTHOSTNAME HOST_ADMIN_IP GATE_HOSTNAME GATE_LAN_IP GATE_ADMIN_IP NS_HOSTNAME NS_LAN_IP NS_ADMIN_IP WAN_IP WAN_GATE LAN_IP LAN_GATE ADMIN_IP ADMIN_GATE ) makePCTTemplateSEDPatterns(){ local vars=("${PCT_TEMPLATE_VARS[@]}" "$@") # strip ips and save to *_IPn var... local ip_vars=() local var local val for var in ${vars[@]} ; do if [[ $var =~ .*_IP ]] ; then local val=${!var} if [[ $val == SKIP ]] ; then val= fi ip_vars+=("${var}n") eval "local ${var}n=\"${val/\/*}\"" fi done makeTemplateSEDPatterns "${vars[@]}" "${ip_vars[@]}" } # # expandTemplate PATH VAR ... # .. | expandTemplate VAR ... # PCT_TEMPLATE_PATTERNS= expandTemplate(){ if [ -t 0 ] ; then local input=$1 shift else local input=/dev/stdin fi if [ -z "$PCT_TEMPLATE_PATTERNS" ] ; then local patterns=($(makeTemplateSEDPatterns "$@")) else local patterns=("${PCT_TEMPLATE_PATTERNS[@]}") fi cat "${input}" \ | eval "sed ${patterns[@]}" } # # expandPCTTemplate PATH [VAR ...] # .. | expandPCTTemplate [VAR ...] # expandPCTTemplate(){ local input= if [ -t 0 ] ; then input=$1 shift fi local PCT_TEMPLATE_PATTERNS=($(makePCTTemplateSEDPatterns "$@")) expandTemplate "${input}" } # # buildAssets [VAR ..] # # XXX add vars in filenames (???) NOTES=NOTES.md buildAssets(){ local template_dir=${TEMPLATE_DIR:-templates} local assets_dir=${ASSETS_DIR:-assets} local staging_dir=${STAGING_DIR:-staging} local PCT_TEMPLATE_PATTERNS=($(makePCTTemplateSEDPatterns "$@")) # assets... if [ -e "${assets_dir}" ] ; then mkdir -p "${staging_dir}" cp -R "${assets_dir}"/* "${staging_dir}"/ fi # template dir... if [ -e $template_dir ] ; then local TEMPLATES=($(find "$template_dir" -type f)) for file in "${TEMPLATES[@]}" ; do file=${file#${template_dir}} echo Generating: ${file}... [ $DRY_RUN ] \ && continue # ensure the directory exists... mkdir -p "$(dirname "${staging_dir}/${file}")" cat "${template_dir}/${file}" \ | expandTemplate \ > "${staging_dir}/${file}" done fi # special case: NOTES.md... if [ -z "$DESCRIPTION" ] && [ -e "$NOTES" ] ; then DESCRIPTION="$(\ cat ${NOTES} \ | expandTemplate)" fi } #---------------------------------------------------------------------- # # pctPushAssets ID # pctPushAssets(){ @ pct-push-r $1 "${STAGING_DIR:-./staging}" / } # # traefikPushConfig # # XXX generate config in a staging location... TRAEFIK_CONFIG=traefik.yml TRAEFIK_PATH=/etc/traefik.d/ TRAEFIK_STAGING=traefik/ traefikPushConfig(){ local filename="${CTHOSTNAME}.yml" local source="${TRAEFIK_STAGING}/${filename}" local target="${TRAEFIK_PATH}"/"${filename}" # source file not found... if ! [ -e "${TRAEFIK_CONFIG}" ] ; then echo "${TRAEFIK_CONFIG}: not found." >&2 return fi # generat config... mkdir -p "${TRAEFIK_STAGING}" cat ${TRAEFIK_CONFIG} \ | expandPCTTemplate \ > "${source}" # get things we need if they are not set... xread "Gate CT id: " GATE_ID # check if $filename exists... if @ lxc-attach $GATE_ID -- test -e ${target} \ && ! xreadYes "Overwrite existing \"${target}\"?" ; then @ lxc-attach $GATE_ID -- mv "${target}" "${target}.bak" fi @ pct push $GATE_ID "${source}" "${target}" } # # pveGetLatestTemplate PATTERN [VAR] # # see: # https://pve.proxmox.com/wiki/Linux_Container pveGetLatestTemplate(){ if [ $DRY_RUN ] ; then [ -z $2 ] \ || eval "$2=${CT_TEMPLATE:-\\\$CT_TEMPLATE}" return fi #@ pveam update local templates=($(pveam available | grep -o ''${1}'.*$')) local latest=${templates[-1]} @ pveam download local ${latest} latest=$(pveam list local | grep -o "^.*$latest") #latest=($(ls /var/lib/vz/template/cache/${1}*)) [ -z $2 ] \ || eval "$2=${latest}" } # # pctBaseCreate ID TEMPLATE ARGS [PASS] # pctBaseCreate(){ local ID=$1 local TEMPLATE=$2 local ARGS=$3 local PASS=$4 local TMP_PASS=$(cat /dev/urandom | base64 | head -c ${TMP_PASS_LEN:=32}) # NOTE: we are not setting the password here to avoid printing it to the terminal... @ pct create $ID \ "${TEMPLATE}" \ ${ARGS} \ --password="$TMP_PASS" \ --start 1 \ || exit 1 # set actual root password... if [ "$PASS" ] ; then echo "root:$PASS" \ | @ lxc-attach $ID chpasswd fi } # # pctCreate ID TEMPLATE [PASS] # #OPTS_STAGE_1= #INTERFACES= #CTHOSTNAME= #CORES= #RAM= #SWAP= #DRIVE= #PCT_EXTRA= pctCreate(){ # build network args... local interfaces_args=() local i=0 local interface for interface in "${INTERFACES[@]}" ; do interfaces_args+=("--net${i} "${interface}"") i=$(( i + 1 )) done # NOTE: TKL gui will not function correctly without nesting enabled... local args="\ --hostname $CTHOSTNAME \ --cores $CORES \ --memory $RAM \ --swap $SWAP \ "${interfaces_args[@]}" \ --storage local-lvm \ --rootfs local-lvm:$DRIVE \ --unprivileged 1 \ --features nesting=1 \ ${PCT_EXTRA} \ " pctBaseCreate "$1" "$2" "${OPTS_STAGE_1:-"${args}"}" "$3" } # # pctCreate ID [PASS] # pctCreateAlpine(){ local TEMPLATE pveGetLatestTemplate alpine TEMPLATE pctCreate $1 "$TEMPLATE" "$2" sleep ${TIMEOUT:=5} @ lxc-attach $1 apk update @ lxc-attach $1 apk upgrade } pctCreateDebian(){ local TEMPLATE pveGetLatestTemplate 'debian-12-standard' TEMPLATE pctCreate $1 "$TEMPLATE" "$2" sleep ${TIMEOUT:=5} @ lxc-attach $1 apt update @ lxc-attach $1 -- apt upgrade -y } pctCreateUbuntu(){ local TEMPLATE pveGetLatestTemplate ubuntu TEMPLATE pctCreate $1 "$TEMPLATE" "$2" sleep ${TIMEOUT:=5} @ lxc-attach $1 apt update @ lxc-attach $1 -- apt upgrade -y } # # pctCreateTurnkey APP ID [PASS] # pctCreateTurnkey(){ local app=$1 shift local TEMPLATE pveGetLatestTemplate '.*-turnkey-'$app TEMPLATE pctCreate $1 "$TEMPLATE" "$2" tklWaitForSetup $1 sleep ${TIMEOUT:=5} } # Wait for /etc/inithooks.conf to be generated then cleared # # tklWaitForSetup ID # # for tkl inithooks doc see: # https://www.turnkeylinux.org/docs/inithooks tklWaitForSetup(){ printf "# TKL setup, this may take a while" if [ -z $DRY_RUN ] ; then while ! $(lxc-attach $1 -- test -e /etc/inithooks.conf) ; do printf '.' sleep ${TIMEOUT:=5} done printf '+' sleep ${TIMEOUT:=5} while ! [[ $(lxc-attach $1 -- cat /etc/inithooks.conf | wc -c) < 2 ]] ; do printf '.' sleep ${TIMEOUT:=5} done else printf '.+..' fi printf 'ready.\n' sleep ${TIMEOUT:=5} } # # pctUpdateTurnkey ID # pctUpdateTurnkey(){ @ lxc-attach $1 apt update @ lxc-attach $1 -- apt upgrade -y } # # pctSet ID [ARGS [REBOOT]] # pctSet(){ [ "$2" ] \ && @ pct set $1 \ ${2} [ "$3" ] \ && @ pct reboot $1 } # # pctSetNotes ID [DESCRIPTION] # pctSetNotes(){ # XXX for some reason this complains quote alot... #[ "$DESCRIPTION" ] \ # && @ pct set $1 \ # "${DESCRIPTION:+--description \""${DESCRIPTION}"\"}" local ID=$1 local NOTES="$(\ echo -e "${2:-${DESCRIPTION}}" \ | sed -e 's/^/#/')" if [ "$DRY_RUN" ] ; then return fi local CONF="$(cat "${CT_DIR}/${ID}.conf")" local TEXT="\ "${NOTES}" "${CONF}" " echo -e "${TEXT}" > "${CT_DIR}/${ID}.conf" } # # showNotes [VAR ...] # BUILD_NOTES=BUILD_NOTES showNotes(){ [ -e "${BUILD_NOTES}" ] \ && mv "${BUILD_NOTES}"{,.bak} [ -e "${BUILD_NOTES}".tpl ] \ && ( cat "${BUILD_NOTES}".tpl \ | expandPCTTemplate $@ \ | tee "${BUILD_NOTES}" ) [ -e ./POST_INSTALL.md ] \ && echo "####" \ && echo ./POST_INSTALL.md \ | sed 's/^/## /' } # # pushNotes ID # pushNotes(){ [ -e "${BUILD_NOTES}" ] \ && @ pct-push-r $1 "${BUILD_NOTES}" /root/ } #---------------------------------------------------------------------- # vim:set ts=4 sw=4 nowrap :