You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1173 lines
37 KiB

  1. #!/bin/bash
  2. . /etc/shlib
  3. include common
  4. include parse
  5. include cmdline
  6. include config
  7. [[ "${BASH_SOURCE[0]}" != "${0}" ]] && SOURCED=true
  8. version=0.1
  9. desc='Manage 0k related installs'
  10. help=""
  11. ##
  12. ## Functions
  13. ##
  14. is-port-open() {
  15. local host="$1" port="$2" timeout=5
  16. start="$SECONDS"
  17. debug "Testing if $host's port $2 is open ..."
  18. while true; do
  19. timeout 1 bash -c "</dev/tcp/${host}/${port}" >/dev/null 2>&1 && break
  20. sleep 0.2
  21. if [ "$((SECONDS - start))" -gt "$timeout" ]; then
  22. return 1
  23. fi
  24. done
  25. }
  26. resolve() {
  27. local ent hostname="$1"
  28. debug "Resolving $1 ..."
  29. if ent=$(getent ahosts "$hostname"); then
  30. ent=$(echo "$ent" | egrep ^"[0-9]+.[0-9]+.[0-9]+.[0-9]+\s+" | \
  31. head -n 1 | awk '{ print $1 }')
  32. debug " .. resolved $1 to $ent."
  33. echo "$ent"
  34. else
  35. debug " .. couldn't resolve $1."
  36. return 1
  37. fi
  38. }
  39. set_errlvl() { return "${1:-1}"; }
  40. export master_pid=$$
  41. ssh:open() {
  42. local hostname ssh_cmd ssh_options
  43. ssh_cmd=(ssh)
  44. ssh_options=()
  45. while [ "$#" != 0 ]; do
  46. case "$1" in
  47. "--stdin-password")
  48. ssh_cmd=(sshpass "${ssh_cmd[@]}")
  49. ;;
  50. -o)
  51. ssh_options+=("$1" "$2")
  52. shift
  53. ;;
  54. *)
  55. [ -z "$hostname" ] && hostname="$1" || {
  56. err "Surnumerous positional argument '$1'. Expecting only hostname."
  57. return 1
  58. }
  59. ;;
  60. esac
  61. shift
  62. done
  63. full_cmd=(
  64. "${ssh_cmd[@]}"
  65. -o ControlPath=/tmp/ssh-control-master-${master_pid}-$hostname \
  66. -o ControlMaster=auto -o ControlPersist=900 \
  67. -o ConnectTimeout=5 -o StrictHostKeyChecking=no \
  68. "${ssh_options[@]}" \
  69. "$hostname" "$@" -- true)
  70. "${full_cmd[@]}" >/dev/null 2>&1 || {
  71. err "Failed: ${full_cmd[*]}"
  72. return 1
  73. }
  74. trap_add EXIT,INT 'ssh:quit "$hostname"'
  75. }
  76. ssh:open-try() {
  77. local opts hostnames
  78. opts=()
  79. hostnames=()
  80. while [ "$#" != 0 ]; do
  81. case "$1" in
  82. -o)
  83. opts+=("$1" "$2")
  84. shift
  85. ;;
  86. *)
  87. hostnames+=("$1")
  88. ;;
  89. esac
  90. shift
  91. done
  92. password=''
  93. for host in "${hostnames[@]}"; do
  94. debug "Trying $host with publickey."
  95. ssh:open -o PreferredAuthentications=publickey \
  96. "${opts[@]}" \
  97. "$host" >/dev/null 2>&1 && {
  98. echo "$host"$'\n'"$password"$'\n'
  99. return 0
  100. }
  101. debug " .. failed connecting to $host with publickey."
  102. done
  103. local times=0 password
  104. while [ "$((++times))" -le 3 ]; do
  105. read -sp "$HOST's password: " password
  106. errlvl="$?"
  107. echo >&2
  108. if [ "$errlvl" -gt 0 ]; then
  109. exit 1
  110. fi
  111. for host in "${hostnames[@]}"; do
  112. debug "Trying $host with password ($times/3)"
  113. echo "$password" | ssh:open -o PreferredAuthentications=password \
  114. --stdin-password \
  115. "${opts[@]}" \
  116. "$host" >/dev/null 2>&1 && {
  117. echo "$host"$'\n'"$password"$'\n'
  118. return 0
  119. }
  120. debug " .. failed connecting to $host with password."
  121. done
  122. err "login failed. Try again... ($((times+1))/3)"
  123. done
  124. return 1
  125. }
  126. ssh:run() {
  127. local hostname="$1" ssh_options cmd
  128. shift
  129. ssh_options=()
  130. cmd=()
  131. while [ "$#" != 0 ]; do
  132. case "$1" in
  133. "--")
  134. shift
  135. cmd+=("$@")
  136. break
  137. ;;
  138. *)
  139. ssh_options+=("$1")
  140. ;;
  141. esac
  142. shift
  143. done
  144. ## XXXvlab: keeping in case we need some debug
  145. # debug "$DARKCYAN$hostname$NORMAL $WHITE\$$NORMAL" "$@"
  146. # debug "Running cmd: ${cmd[*]}"
  147. # for arg in "${cmd[@]}"; do
  148. # debug "$arg"
  149. # done
  150. {
  151. {
  152. ssh -o ControlPath=/tmp/ssh-control-master-${master_pid}-$hostname \
  153. -o ControlMaster=auto -o ControlPersist=900 \
  154. -o "StrictHostKeyChecking=no" \
  155. "${ssh_options[@]}" "$hostname" -- "${cmd[@]}"
  156. } 3>&1 1>&2 2>&3 ## | sed -r "s/^/$DARKCYAN$hostname$NORMAL $DARKRED\!$NORMAL /g"
  157. set_errlvl "${PIPESTATUS[0]}"
  158. } 3>&1 1>&2 2>&3
  159. }
  160. ssh:quit() {
  161. local hostname="$1"
  162. shift
  163. ssh -o ControlPath=/tmp/ssh-control-master-${master_pid} \
  164. -o ControlMaster=auto -o ControlPersist=900 -O exit \
  165. "$hostname" 2>/dev/null
  166. }
  167. is_ovh_domain_name() {
  168. local domain="$1"
  169. [[ "$domain" == *.ovh.net ]] && return 0
  170. [[ "$domain" == "ns"*".ip-"*".eu" ]] && return 0
  171. return 1
  172. }
  173. is_ovh_hostname() {
  174. local domain="$1"
  175. [[ "$domain" =~ ^vps-[0-9a-f]*$ ]] && return 0
  176. [[ "$domain" =~ ^vps[0-9]*$ ]] && return 0
  177. return 1
  178. }
  179. vps_connection_check() {
  180. local vps="$1"
  181. ip=$(resolve "$vps") ||
  182. { echo "${DARKRED}no-resolve${NORMAL}"; return 1; }
  183. is-port-open "$ip" "22" </dev/null ||
  184. { echo "${DARKRED}no-port-22-open${NORMAL}"; return 1; }
  185. ssh:open -o ConnectTimeout=10 -o PreferredAuthentications=publickey \
  186. "root@$vps" >/dev/null 2>&1 ||
  187. { echo "${DARKRED}no-ssh-root-access${NORMAL}"; return 1; }
  188. }
  189. vps_check() {
  190. local vps="$1"
  191. vps_connection_check "$vps" </dev/null || return 1
  192. if size=$(
  193. echo "df /srv -h | tail -n +2 | sed -r 's/ +/ /g' | cut -f 5 -d ' ' | cut -f 1 -d %" |
  194. ssh:run "root@$vps" -- bash); then
  195. if [ "$size" -gt "90" ]; then
  196. echo "${DARKRED}above-90%-disk-usage${NORMAL}"
  197. elif [ "$size" -gt "75" ]; then
  198. echo "${DARKYELLOW}above-75%-disk-usage${NORMAL}"
  199. fi
  200. else
  201. echo "${DARKRED}no-size${NORMAL}"
  202. fi </dev/null
  203. compose_content=$(ssh:run "root@$vps" -- cat /opt/apps/myc-deploy/compose.yml </dev/null) ||
  204. { echo "${DARKRED}no-compose${NORMAL}"; return 1; }
  205. echo "$compose_content" | grep backup >/dev/null 2>&1 ||
  206. { echo "${DARKRED}no-backup${NORMAL}"; return 1; }
  207. }
  208. backup:setup-rsync() {
  209. local admin="$1" vps="$2" server="$3" id="$4"
  210. [ -z "${BACKUP_SSH_SERVER}" ] || {
  211. err "Unexpected error: '\$BACKUP_SSH_SERVER' is already set in '$FUNCNAME'."
  212. return 1
  213. }
  214. BACKUP_SSH_OPTIONS=(-o StrictHostKeyChecking=no)
  215. if [[ "$server" == *":"* ]]; then
  216. BACKUP_SSH_OPTIONS+=(-p "${server#*:}")
  217. BACKUP_SSH_SERVER=${server%%:*}
  218. else
  219. BACKUP_SSH_SERVER="$server"
  220. fi
  221. if ! private_key=$(ssh "${BACKUP_SSH_OPTIONS[@]}" \
  222. "$admin"@"${BACKUP_SSH_SERVER}" request-recovery-key "$id"); then
  223. err "Couldn't request a recovery key for '$id' with account '$admin'."
  224. return 1
  225. fi
  226. if ! VPS_TMP_DIR=$(echo "mktemp -d" | ssh:run "root@$vps" -- bash); then
  227. err "Couldn't create a temporary directory on vps"
  228. return 1
  229. fi
  230. cat <<EOF | ssh:run "root@$vps" -- bash || return 1
  231. touch "$VPS_TMP_DIR/recover_key" &&
  232. chmod go-rwx "$VPS_TMP_DIR/recover_key" &&
  233. printf "%s\n" "$private_key" >> "$VPS_TMP_DIR/recover_key"
  234. EOF
  235. BACKUP_SSH_OPTIONS+=(-i "$VPS_TMP_DIR/recover_key" -l rsync)
  236. BACKUP_VPS_TARGET="$vps"
  237. BACKUP_IDENT="$id"
  238. echo "type -p rsync >/dev/null 2>&1 || apt-get install -y rsync </dev/null" |
  239. ssh:run "root@$vps" -- bash || return 1
  240. }
  241. backup:rsync() {
  242. local ssh_options
  243. [ -n "${BACKUP_SSH_SERVER}" ] || {
  244. err "Unexpected error: '\$BACKUP_SSH_SERVER' is not set in 'rsync_exists'."
  245. return 1
  246. }
  247. rsync_options=()
  248. while [[ "$1" == "-"* ]]; do
  249. rsync_options+=("$1")
  250. shift
  251. done
  252. local src="$1" dst="$2"
  253. cat <<EOF | ssh:run "root@${BACKUP_VPS_TARGET}" -- bash
  254. rsync -e "ssh ${BACKUP_SSH_OPTIONS[*]}" \
  255. -azvArH --delete --delete-excluded \
  256. --partial --partial-dir .rsync-partial \
  257. --numeric-ids ${rsync_options[*]} \
  258. "${BACKUP_SSH_SERVER}":/var/mirror/"${BACKUP_IDENT}${src}" "${dst}"
  259. EOF
  260. }
  261. backup:path_exists() {
  262. local src="$1"
  263. [ -n "${BACKUP_SSH_SERVER}" ] || {
  264. err "Unexpected error: '\$BACKUP_SSH_SERVER' is not set in 'rsync_exists'."
  265. return 1
  266. }
  267. cat <<EOF | ssh:run "root@${BACKUP_VPS_TARGET}" -- bash >/dev/null 2>&1
  268. rsync -e "ssh ${BACKUP_SSH_OPTIONS[*]}" \
  269. -nazvArH --numeric-ids \
  270. "${BACKUP_SSH_SERVER}":/var/mirror/"${BACKUP_IDENT}${src}" "/tmp/dummy"
  271. EOF
  272. }
  273. file:vps_backup_recover() {
  274. local admin="$1" server="$2" id="$3" path="$4" vps="$5" vps_path="$6"
  275. backup:rsync "${path}" "${vps_path}" || return 1
  276. if [[ "$path" == *"/" ]]; then
  277. if [ "$path" == "$vps_path"/ ]; then
  278. msg_target="Directory '$path'"
  279. else
  280. msg_target="Directory '$path' -> '$vps_path'"
  281. fi
  282. else
  283. if [ "$path" == "$vps_path" ]; then
  284. msg_target="File '$path'"
  285. else
  286. msg_target="File '$path' -> '$vps_path'"
  287. fi
  288. fi
  289. info "$msg_target was ${GREEN}successfully${NORMAL} restored on $vps."
  290. }
  291. mailcow:vps_backup_recover() {
  292. local admin="$1" server="$2" id="$3" path="$4" vps="$5" vps_path="$6"
  293. if ! compose_yml_files=$(cat <<EOF | ssh:run "root@$vps" -- bash
  294. urn=com.docker.compose.project
  295. docker ps -f "label=\$urn=mailcowdockerized" \
  296. --format="{{.Label \"\$urn.working_dir\"}}/{{.Label \"\$urn.config_files\"}}" |
  297. uniq
  298. EOF
  299. ); then
  300. err "Couldn't get list of running projects"
  301. return 1
  302. fi
  303. stopped_containers=
  304. if [ -n "$compose_yml_files" ]; then
  305. echo "Found running mailcowdockerized containers" >&2
  306. if [[ "$compose_yml_files" == *$'\n'* ]]; then
  307. err "Running containers are confusing, did not find only one mailcowdockerized project."
  308. return 1
  309. fi
  310. if ! echo "[ -e \"$compose_yml_files\" ]" | ssh:run "root@$vps" -- bash ; then
  311. ## For some reason, sometimes $urn.config_files holds an absolute path
  312. compose_yml_files=/${compose_yml_files#*//}
  313. if ! echo "[ -e \"$compose_yml_files\" ]" | ssh:run "root@$vps" -- bash ; then
  314. err "Running containers are confusing, they don't point to an existing docker-compose.yml."
  315. return 1
  316. fi
  317. fi
  318. echo "Containers where launched from '$compose_yml_files'" >&2
  319. COMPOSE_FILE="$compose_yml_files"
  320. ENV_FILE="${COMPOSE_FILE%/*}/.env"
  321. if ! echo "[ -e \"${ENV_FILE}\" ]" | ssh:run "root@$vps" -- bash ; then
  322. err "Running containers are confusing, docker-compose.yml has no '.env' next to it."
  323. return 1
  324. fi
  325. echo "${WHITE}Bringing mailcowdockerized down${NORMAL}"
  326. echo "docker-compose -f \"${COMPOSE_FILE}\" --env-file \"${ENV_FILE}\" down" |
  327. ssh:run "root@$vps" -- bash
  328. stopped_containers=1
  329. fi
  330. if [[ "$path" == "/"* ]]; then
  331. ##
  332. ## Additional intelligence to simple file copy
  333. ##
  334. if [[ "$path" == "/var/lib/docker/volumes/mailcowdockerized_*-vol-1/_data"* ]]; then
  335. volume_name=${path#/var/lib/docker/volumes/}
  336. volume_name=${volume_name%%/*}
  337. volume_dir=${path%%"$volume_name"*}
  338. ## Create volumes if not existent
  339. if ! ssh:run "root@$vps" -- "
  340. [ -d '${volume_dir}' ] ||
  341. docker run --rm -v ${volume_name}:/tmp/dummy docker.0k.io/alpine:3.9
  342. [ -d '${volume_dir}' ]
  343. "; then
  344. err "Couldn't find nor create '${volume_dir}'."
  345. return 1
  346. fi
  347. fi
  348. echo "${WHITE}Sync from backup ${path} to VPS ${vps_path}${NORMAL}" >&2
  349. backup:rsync "${path}" "${vps_path}" || return 1
  350. if [[ "$path" == *"/" ]]; then
  351. if [ "$path" == "$vps_path"/ ]; then
  352. msg_target="Directory '$path'"
  353. else
  354. msg_target="Directory '$path' -> '$vps_path'"
  355. fi
  356. else
  357. if [ "$path" == "$vps_path" ]; then
  358. msg_target="File '$path'"
  359. else
  360. msg_target="File '$path' -> '$vps_path'"
  361. fi
  362. fi
  363. else
  364. ALL_TARGETS=(mailcow postfix rspamd redis crypt vmail{,-attachments} mysql)
  365. if [[ -n "$path" ]]; then
  366. targets=()
  367. bad_targets=()
  368. for target in ${path//,/ }; do
  369. if [[ " ${ALL_TARGETS[*]} " != *" $target "* ]]; then
  370. bad_targets+=("$target")
  371. fi
  372. targets+=("$target")
  373. done
  374. if [ "${#bad_targets[@]}" -gt 0 ]; then
  375. bad_target_msg=$(printf "%s, " "${bad_targets[@]}")
  376. err "Unknown components: ${bad_target_msg%, }. These are allowed components:"
  377. printf " - %s\n" "${ALL_TARGETS[@]}" >&2
  378. return 1
  379. fi
  380. msg_target="Partial mailcow backup"
  381. else
  382. targets=("${ALL_TARGETS[@]}")
  383. msg_target="Full mailcow backup"
  384. fi
  385. for target in "${targets[@]}"; do
  386. case "$target" in
  387. postfix|rspamd|redis|crypt|vmail|vmail-attachments)
  388. volume_name="mailcowdockerized_${target}-vol-1"
  389. volume_dir="/var/lib/docker/volumes/${volume_name}/_data"
  390. if ! backup:path_exists "${volume_dir}/"; then
  391. warn "No '$volume_name' in backup. This might be expected."
  392. continue
  393. fi
  394. ## Create volumes if not existent
  395. if ! ssh:run "root@$vps" -- "
  396. [ -d '${volume_dir}' ] ||
  397. docker run --rm -v ${volume_name}:/tmp/dummy docker.0k.io/alpine:3.9
  398. [ -d '${volume_dir}' ]
  399. "; then
  400. err "Couldn't find nor create '${volume_dir}'."
  401. return 1
  402. fi
  403. echo "${WHITE}Downloading of $volume_name${NORMAL}"
  404. backup:rsync "${volume_dir}/" "${volume_dir}" || return 1
  405. ;;
  406. mailcow)
  407. ## Mailcow git base
  408. COMPOSE_FILE=
  409. for mailcow_dir in /opt/{apps/,}mailcow-dockerized; do
  410. backup:path_exists "${mailcow_dir}/" || continue
  411. ## this possibly change last value
  412. COMPOSE_FILE="$mailcow_dir/docker-compose.yml"
  413. ENV_FILE="$mailcow_dir/.env"
  414. echo "${WHITE}Download of $mailcow_dir${NORMAL}"
  415. backup:rsync "${mailcow_dir}"/ "${mailcow_dir}" || return 1
  416. break
  417. done
  418. if [ -z "$COMPOSE_FILE" ]; then
  419. err "Can't find mailcow base installation path in backup."
  420. return 1
  421. fi
  422. ;;
  423. mysql)
  424. if [ -z "$COMPOSE_FILE" ]; then
  425. ## Mailcow git base
  426. compose_files=()
  427. for mailcow_dir in /opt/{apps/,}mailcow-dockerized; do
  428. ssh:run "root@$vps" -- "[ -e \"$mailcow_dir/docker-compose.yml\" ]" || continue
  429. ## this possibly change last value
  430. compose_files+=("$mailcow_dir/docker-compose.yml")
  431. done
  432. if [ "${#compose_files[@]}" == 0 ]; then
  433. err "No compose file found for mailcow installation."
  434. return 1
  435. elif [ "${#compose_files[@]}" -gt 1 ]; then
  436. err "Multiple compose files for mailcow found:"
  437. for f in "${compose_files[@]}"; do
  438. echo " - $f" >&2
  439. done
  440. echo "Can't decide which to use for mounting mysql container." >&2
  441. return 1
  442. fi
  443. COMPOSE_FILE="${compose_files[0]}"
  444. ENV_FILE="${COMPOSE_FILE%/*}/.env"
  445. if ! ssh:run "root@$vps" -- "[ -e \"${COMPOSE_FILE%/*}/.env\" ]"; then
  446. err "No env file in '$ENV_FILE' found."
  447. return 1
  448. fi
  449. fi
  450. ## Mysql database
  451. echo "${WHITE}Downloading last backup of mysql backups${NORMAL}"
  452. backup:rsync "/var/backups/mysql/" "/var/backups/mysql" || return 1
  453. if ! env_content=$(echo "cat '$ENV_FILE'" | ssh:run "root@$vps" -- bash); then
  454. err "Can't access env file: '$ENV_FILE'."
  455. return 1
  456. fi
  457. root_password=$(printf "%s\n" "$env_content" | grep ^DBROOT= | cut -f 2 -d =)
  458. echo "${WHITE}Bringing mysql-mailcow up${NORMAL}"
  459. if ! image=$(cat <<EOF | ssh:run "root@$vps" -- bash
  460. shyaml get-value services.mysql-mailcow.image < "${COMPOSE_FILE}"
  461. EOF
  462. ); then
  463. err "Failed to get image name of service 'mysql-mailcow' in 'compose.yml'."
  464. return 1
  465. fi
  466. if [ -z "$(ssh:run "root@$vps" -- docker images -q "$image")" ]; then
  467. info "Image '$image' not available, pull it."
  468. if ! ssh:run "root@$vps" -- \
  469. docker-compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" \
  470. pull mysql-mailcow; then
  471. err "Failed to pull image of service 'mysql-mailcow'."
  472. return 1
  473. fi
  474. fi
  475. if ! container_id=$(cat <<EOF | ssh:run "root@$vps" -- bash
  476. echo "[client]
  477. password=$root_password" > "$VPS_TMP_DIR/my.cnf"
  478. docker-compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" \
  479. run -d \
  480. -v "$VPS_TMP_DIR/my.cnf:/root/.my.cnf:ro" \
  481. mysql-mailcow
  482. EOF
  483. ); then
  484. err "Failed to bring up mysql-mailcow"
  485. return 1
  486. fi
  487. START="$SECONDS"
  488. retries=0
  489. timeout=600
  490. while true; do
  491. ((retries++))
  492. echo " waiting for mysql db..." \
  493. "(retry $retries, $(($SECONDS - $START))s elapsed, timeout is ${timeout}s)" >&2
  494. cat <<EOF | ssh:run "root@$vps" -- bash && break
  495. echo "SELECT 1;" | docker exec -i "$container_id" mysql >/dev/null 2>&1
  496. EOF
  497. if (($SECONDS - $START > $timeout)); then
  498. err "Failed to connect to mysql-mailcow."
  499. echo "docker-compose -f \"${COMPOSE_FILE}\" --env-file \"${ENV_FILE}\" down" |
  500. ssh:run "root@$vps" -- bash
  501. return 1
  502. fi
  503. sleep 0.4
  504. done
  505. DBUSER=$(printf "%s\n" "$env_content" | grep ^DBUSER= | cut -f 2 -d =)
  506. DBPASS=$(printf "%s\n" "$env_content" | grep ^DBPASS= | cut -f 2 -d =)
  507. echo "${WHITE}Uploading mysql dump${NORMAL}"
  508. cat <<EOF | ssh:run "root@$vps" -- bash
  509. echo "
  510. DROP DATABASE IF EXISTS mailcow;
  511. CREATE DATABASE mailcow;
  512. GRANT ALL PRIVILEGES ON mailcow.* TO '$DBUSER'@'%' IDENTIFIED BY '$DBPASS';
  513. " | docker exec -i "$container_id" mysql
  514. zcat /var/backups/mysql/mailcow/*.gz | docker exec -i "$container_id" mysql mailcow
  515. EOF
  516. if [ "$?" != 0 ]; then
  517. err "Failed to load mysql dump."
  518. echo "docker-compose -f \"${COMPOSE_FILE}\" --env-file \"${ENV_FILE}\" down" |
  519. ssh:run "root@$vps" -- bash
  520. return 1
  521. fi
  522. echo "${WHITE}Bringing mysql-mailcow down${NORMAL}"
  523. echo "docker-compose -f \"${COMPOSE_FILE}\" --env-file \"${ENV_FILE}\" down" |
  524. ssh:run "root@$vps" -- bash
  525. ;;
  526. *)
  527. err "Unknown component '$target'. Bailing out."
  528. return 1
  529. esac
  530. done
  531. ssh:run "root@$vps" -- "rm -rf '$VPS_TMP_DIR'"
  532. fi
  533. if [ -n "$stopped_containers" ]; then
  534. echo "${WHITE}Starting mailcow${NORMAL}" >&2
  535. echo "docker-compose -f \"${COMPOSE_FILE}\" --env-file \"${ENV_FILE}\" up -d" |
  536. ssh:run "root@$vps" -- bash
  537. fi
  538. info "$msg_target was ${GREEN}successfully${NORMAL} restored on $vps."
  539. }
  540. vps_backup_recover() {
  541. local vps="$1" admin server id path rtype force type
  542. read-0 admin server id path rtype force
  543. if [[ "$vps" == *":"* ]]; then
  544. vps_path=${vps#*:}
  545. vps=${vps%%:*}
  546. else
  547. vps_path=
  548. fi
  549. vps_connection_check "$vps" </dev/null || {
  550. err "Failed to access '$vps'."
  551. return 1
  552. }
  553. if type=$(ssh:run "root@$vps" -- vps get-type); then
  554. info "VPS $vps seems to be of ${WHITE}$type${NORMAL} type"
  555. else
  556. warn "Couldn't find type of vps '$vps' (command 'vps get-type' failed on vps)."
  557. fi
  558. if [ -z "$path" ]; then
  559. if [ -n "$vps_path" ]; then
  560. err "You can't provide a VPS with path as destination if you don't provide a path in backup source."
  561. return 1
  562. fi
  563. info "No path provided in backup, so we assume you want ${WHITE}full recovery${NORMAL}."
  564. if [ "$rtype" != "$type" ]; then
  565. if [ -n "$force" ]; then
  566. warn "Backup found is of ${rtype:-unknown} type, while vps is of ${type:-unknown} type."
  567. else
  568. err "Backup found is of ${rtype:-unknown} type, while vps is of ${type:-unknown} type. (use \`\`-f\`\` to force)"
  569. return 1
  570. fi
  571. fi
  572. else
  573. if [ "$path" == "/" ]; then
  574. if [ -z "$vps_path" ]; then
  575. err "Recovery of '/' (full backup files) requires that you provide a vps path also."
  576. return 1
  577. fi
  578. if [ "$vps_path" == "/" ]; then
  579. err "Recovery of '/' (full backup files) requires that you provide" \
  580. "a vps path different from '/' also."
  581. return 1
  582. fi
  583. fi
  584. fi
  585. ## Sets VPS and internal global variable to allow rsync to work
  586. ## from vps to backup server.
  587. backup:setup-rsync "$admin" "$vps" "$server" "$id" || return 1
  588. if [[ "$path" == "/"* ]]; then
  589. if ! backup:path_exists "${path}"; then
  590. err "File or directory '$path' not found in backup."
  591. return 1
  592. fi
  593. if [ -z "$vps_path" ]; then
  594. if [[ "$path" != *"/" ]] && backup:path_exists "${path}"/ ; then
  595. path="$path/"
  596. fi
  597. vps_path=${path%/}
  598. vps_path=${vps_path:-/}
  599. fi
  600. fi
  601. case "$rtype-$type" in
  602. mailcow-*)
  603. ## Supports having $path and $vps_path set or unset, with additional behavior
  604. mailcow:vps_backup_recover "$admin" "$server" "$id" "$path" "$vps" "$vps_path"
  605. ;;
  606. *-*)
  607. if [[ "$path" == "/"* ]]; then
  608. ## For now, will require having $path and $vps_path set, no additional behaviors
  609. file:vps_backup_recover "$admin" "$server" "$id" "$path" "$vps" "$vps_path"
  610. else
  611. if [ -n "$path" ]; then
  612. err "Partial component recover of ${rtype:-unknown} backup type on" \
  613. "${type:-unknown} type VPS is not yet implemented."
  614. return 1
  615. else
  616. err "Full recover of ${rtype:-unknown} backup type on" \
  617. "${type:-unknown} type VPS is not yet implemented."
  618. return 1
  619. fi
  620. fi
  621. ;;
  622. esac
  623. }
  624. vps_install_backup() {
  625. local vps="$1" admin server
  626. vps_connection_check "$vps" </dev/null || return 1
  627. read-0 admin server
  628. if ! type=$(ssh:run "root@$vps" -- vps get-type); then
  629. err "Could not get type."
  630. return 1
  631. fi
  632. if ! out=$(ssh:run "root@$vps" -- vps install backup "$server" 2>&1); then
  633. err "Command 'vps install backup $server' failed."
  634. return 1
  635. fi
  636. out="${out%$'\n'}"
  637. out="${out#*$'\n'}"
  638. key="${out%\'*}"
  639. key="${key##*\'}"
  640. if ! [[ "$key" =~ ^"ssh-rsa "[a-zA-Z0-9/+]+" "[a-zA-Z0-9._-]+"@"[a-zA-Z0-9._-]+$ ]]; then
  641. err "Unexpected output from 'vps install backup $server'. Can't find key."
  642. echo "$out" | prefix " ${GRAY}|$NORMAL " >&2
  643. echo " Extracted key:" >&2
  644. echo "$key" | prefix " ${GRAY}|$NORMAL " >&2
  645. return 1
  646. fi
  647. if [ "$type" == "compose" ]; then
  648. if ! ssh:run "root@$vps" -- \
  649. docker exec myc_cron_1 \
  650. cat /etc/cron.d/rsync-backup >/dev/null 2>&1; then
  651. ssh:run "root@$vps" -- compose --debug up || {
  652. err "Command 'compose --debug up' failed."
  653. return 1
  654. }
  655. if ! ssh:run "root@$vps" -- \
  656. docker exec myc_cron_1 \
  657. cat /etc/cron.d/rsync-backup >/dev/null 2>&1; then
  658. err "Launched 'compose up' successfully but ${YELLOW}cron${NORMAL} container is not setup as expected."
  659. echo " Was waiting for existence of '/etc/cron.d/rsync-backup' in it." >&2
  660. return 1
  661. fi
  662. fi
  663. fi
  664. dest="$server"
  665. dest="${dest%/*}"
  666. ssh_options=()
  667. if [[ "$dest" == *":"* ]]; then
  668. port="${dest##*:}"
  669. dest="${dest%%:*}"
  670. ssh_options=(-p "$port")
  671. else
  672. port=""
  673. dest="${dest%%:*}"
  674. fi
  675. cmd=(ssh "${ssh_options[@]}" "$admin"@"$dest" ssh-key add "$key")
  676. echo "${WHITE}Launching:${NORMAL} ${cmd[@]}"
  677. "${cmd[@]}" || {
  678. err "Failed add key to backup server '$dest'."
  679. return 1
  680. }
  681. echo "${WHITE}Launching backup${NORMAL} from '$vps'"
  682. ssh:run "root@$vps" -- vps backup || {
  683. err "First backup failed to run."
  684. return 1
  685. }
  686. echo "Backup is ${GREEN}up and running${NORMAL}."
  687. }
  688. vps_udpate() {
  689. local vps="$1"
  690. vps_connection_check "$vps" || return 1
  691. ssh:run "root@$vps" -- myc-update </dev/null
  692. }
  693. vps_bash() {
  694. local vps="$1"
  695. vps_connection_check "$vps" </dev/null || return 1
  696. ssh:run "root@$vps" -- bash
  697. }
  698. vps_mux() {
  699. local fn="$1" vps_done VPS max_size vps
  700. shift
  701. VPS=($(printf "%s\n" "$@" | sort))
  702. max_size=0
  703. declare -A vps_done;
  704. new_vps=()
  705. for name in "${VPS[@]}"; do
  706. [ -n "${vps_done[$name]}" ] && {
  707. warn "duplicate vps '$name' provided. Ignoring."
  708. continue
  709. }
  710. vps_done["$name"]=1
  711. new_vps+=("$name")
  712. size_name="${#name}"
  713. [ "$max_size" -lt "${size_name}" ] &&
  714. max_size="$size_name"
  715. done
  716. settmpdir "_0KM_TMP_DIR"
  717. cat > "$_0KM_TMP_DIR/code"
  718. for vps in "${new_vps[@]}"; do
  719. label=$(printf "%-${max_size}s" "$vps")
  720. (
  721. {
  722. {
  723. "$fn" "$vps" < "$_0KM_TMP_DIR/code"
  724. } 3>&1 1>&2 2>&3 | sed -r "s/^/$DARKCYAN$label$NORMAL $DARKRED\!$NORMAL /g"
  725. set_errlvl "${PIPESTATUS[0]}"
  726. } 3>&1 1>&2 2>&3 | sed -r "s/^/$DARKCYAN$label$NORMAL $DARKGRAY\|$NORMAL /g"
  727. set_errlvl "${PIPESTATUS[0]}"
  728. ) &
  729. done
  730. wait
  731. }
  732. [ "$SOURCED" ] && return 0
  733. ##
  734. ## Command line processing
  735. ##
  736. cmdline.spec.gnu
  737. cmdline.spec.reporting
  738. cmdline.spec.gnu vps-setup
  739. cmdline.spec::cmd:vps-setup:run() {
  740. : :posarg: HOST 'Target host to check/fix ssh-access'
  741. depends sshpass shyaml
  742. KEY_PATH="ssh-access.public-keys"
  743. local keys=$(config get-value -y "ssh-access.public-keys") || true
  744. if [ -z "$keys" ]; then
  745. err "No ssh publickeys configured in config file."
  746. echo " Looking for keys in ${WHITE}${KEY_PATH}${NORMAL}" \
  747. "in config file." >&2
  748. config:exists --message 2>&1 | prefix " "
  749. if [ "${PIPESTATUS[0]}" == "0" ]; then
  750. echo " Config file found in $(config:filename)"
  751. fi
  752. return 1
  753. fi
  754. local tkey=$(e "$keys" | shyaml get-type)
  755. if [ "$tkey" != "sequence" ]; then
  756. err "Value type of ${WHITE}${KEY_PATH}${NORMAL} unexpected (is $tkey, expecting sequence)."
  757. echo " Check content of $(config:filename), and make sure to use a sequence." >&2
  758. return 1
  759. fi
  760. local IP NAME keys host_pass_connected
  761. if ! IP=$(resolve "$HOST"); then
  762. err "'$HOST' name unresolvable."
  763. exit 1
  764. fi
  765. NAME="$HOST"
  766. if [ "$IP" != "$HOST" ]; then
  767. NAME="$HOST ($IP)"
  768. fi
  769. if ! is-port-open "$IP" "22"; then
  770. err "$NAME unreachable or port 22 closed."
  771. exit 1
  772. fi
  773. debug "Host $IP's port 22 is open."
  774. if ! host_pass_connected=$(ssh:open-try \
  775. {root,debian}@"$HOST"); then
  776. err "Could not connect to {root,debian}@$HOST with publickey nor password."
  777. exit 1
  778. fi
  779. read-0a host password <<<"$host_pass_connected"
  780. sudo_if_necessary=
  781. if [ "$password" -o "${host%%@*}" != "root" ]; then
  782. if ! ssh:run "$host" -- sudo -n true >/dev/null 2>&1; then
  783. err "Couldn't do a password-less sudo from $host."
  784. echo " This is not yet supported."
  785. exit 1
  786. else
  787. sudo_if_necessary=sudo
  788. fi
  789. fi
  790. Section Checking access
  791. while read-0 key; do
  792. prefix="${key%% *}"
  793. if [ "$prefix" != "ssh-rsa" ]; then
  794. err "Unsupported key:"$'\n'"$key"
  795. return 1
  796. fi
  797. label="${key##* }"
  798. Elt "considering adding key ${DARKYELLOW}$label${NORMAL}"
  799. dest="/root/.ssh/authorized_keys"
  800. if ssh:run "$host" -- $sudo_if_necessary grep "\"$key\"" "$dest" >/dev/null 2>&1 </dev/null; then
  801. print_info "already present"
  802. print_status noop
  803. Feed
  804. else
  805. if echo "$key" | ssh:run "$host" -- $sudo_if_necessary tee -a "$dest" >/dev/null; then
  806. print_info added
  807. else
  808. echo
  809. Feedback failure
  810. return 1
  811. fi
  812. Feedback success
  813. fi
  814. done < <(e "$keys" | shyaml get-values-0)
  815. Section Checking ovh hostname file
  816. Elt "Checking /etc/ovh-hostname"
  817. if ! ssh:run "$host" -- $sudo_if_necessary [ -e "/etc/ovh-hostname" ]; then
  818. print_info "creating"
  819. ssh:run "$host" -- $sudo_if_necessary cp /etc/hostname /etc/ovh-hostname
  820. ovhname=$(ssh:run "$host" -- $sudo_if_necessary cat /etc/ovh-hostname)
  821. Elt "Checking /etc/ovh-hostname: $ovhname"
  822. Feedback || return 1
  823. else
  824. ovhname=$(ssh:run "$host" -- $sudo_if_necessary cat /etc/ovh-hostname)
  825. Elt "Checking /etc/ovh-hostname: $ovhname"
  826. print_info "already present"
  827. print_status noop
  828. Feed
  829. fi
  830. if ! is_ovh_domain_name "$HOST" && ! str_is_ipv4 "$HOST"; then
  831. Section Checking hostname
  832. Elt "Checking /etc/hostname..."
  833. if [ "$old" != "$HOST" ]; then
  834. old="$(ssh:run "$host" -- $sudo_if_necessary cat /etc/hostname)"
  835. Elt "Hostname is '$old'"
  836. if is_ovh_hostname "$old"; then
  837. Elt "Hostname '$old' --> '$HOST'"
  838. print_info "creating"
  839. echo "$HOST" | ssh:run "$host" -- $sudo_if_necessary tee /etc/hostname >/dev/null &&
  840. ssh:run "$host" -- $sudo_if_necessary hostname "$HOST"
  841. Feedback || return 1
  842. else
  843. print_info "not changing"
  844. print_status noop
  845. Feed
  846. fi
  847. else
  848. print_info "already set"
  849. print_status noop
  850. Feed
  851. fi
  852. else
  853. info "Not changing domain as '$HOST' doesn't seem to be final domain."
  854. fi
  855. }
  856. cmdline.spec.gnu vps-check
  857. cmdline.spec::cmd:vps-check:run() {
  858. : :posarg: [VPS...] 'Target host(s) to check'
  859. echo "" |
  860. vps_mux vps_check "${VPS[@]}"
  861. }
  862. cmdline.spec.gnu vps-install
  863. cmdline.spec::cmd:vps-install:run() {
  864. :
  865. }
  866. cmdline.spec.gnu backup
  867. cmdline.spec:vps-install:cmd:backup:run() {
  868. : :posarg: BACKUP_TARGET 'Backup target.
  869. (ie: myadmin@backup.domain.org:10023/256)'
  870. : :posarg: [VPS...] 'Target host(s) to check'
  871. if [ "${#VPS[@]}" == 0 ]; then
  872. warn "VPS list provided in command line is empty. Nothing will be done."
  873. return 0
  874. fi
  875. if ! [[ "$BACKUP_TARGET" == *"@"* ]]; then
  876. err "Missing admin account identifier in backup target."
  877. echo " Have you forgottent to specify an account, ie 'myadmin@<MYBACKUP_SERVER>' ?)"
  878. return 1
  879. fi
  880. admin=${BACKUP_TARGET%%@*}
  881. server=${BACKUP_TARGET#*@}
  882. p0 "$admin" "$server" |
  883. vps_mux vps_install_backup "${VPS[@]}"
  884. }
  885. cmdline.spec.gnu vps-backup
  886. cmdline.spec::cmd:vps-backup:run() {
  887. :
  888. }
  889. cmdline.spec.gnu ls
  890. cmdline.spec:vps-backup:cmd:ls:run() {
  891. : :posarg: BACKUP_ID 'Backup id.
  892. (ie: myadmin@backup.domain.org:10023)'
  893. if ! [[ "$BACKUP_ID" == *"@"* ]]; then
  894. err "Missing admin account identifier in backup id."
  895. echo " Have you forgottent to specify an admin account ?" \
  896. "ie 'myadmin@<MYBACKUP_SERVER>#<BACKUP_IDENT>' ?)"
  897. return 1
  898. fi
  899. id=${BACKUP_ID##*#}
  900. BACKUP_TARGET=${BACKUP_ID%#*}
  901. admin=${BACKUP_TARGET%%@*}
  902. server=${BACKUP_TARGET#*@}
  903. ## XXXvlab: in this first implementation we expect to have access
  904. ## to the server main ssh port 22, so we won't use the provided port.
  905. ssh_options=()
  906. if [[ "$server" == *":"* ]]; then
  907. ssh_options+=(-p "${server#*:}")
  908. server=${server%%:*}
  909. fi
  910. ssh "${ssh_options[@]}" "$admin"@"$server" ssh-key ls
  911. }
  912. cmdline.spec.gnu recover
  913. cmdline.spec:vps-backup:cmd:recover:run() {
  914. : :posarg: BACKUP_ID 'Backup id.
  915. (ie: myadmin@backup.domain.org:10023#mx.myvps.org
  916. myadmin@ark-01.org#myid:/a/path
  917. admin@ark-02.io#myid:myqsl,mailcow)'
  918. : :posarg: VPS_PATH 'Target host(s) to check.
  919. (ie: myvps.com
  920. myvps.com:/a/path)'
  921. : :optval: --date,-D '"last", or label of version to recover. (Default: "last").'
  922. : :optfla: --force,-f 'Will allow you to bypass some checks.'
  923. if ! [[ "$BACKUP_ID" == *"@"* ]]; then
  924. err "Missing admin account identifier in backup id."
  925. echo " Have you forgottent to specify an admin account ?" \
  926. "ie 'myadmin@<MYBACKUP_SERVER>#<BACKUP_IDENT>' ?)"
  927. return 1
  928. fi
  929. if ! [[ "$BACKUP_ID" == *"@"*"#"* ]]; then
  930. err "Missing backup label identifier in backup id."
  931. echo " Have you forgottent to specify a backup label identifier ?" \
  932. "ie 'myadmin@<MYBACKUP_SERVER>#<BACKUP_IDENT>' ?)"
  933. return 1
  934. fi
  935. id_path=${BACKUP_ID#*#}
  936. if [[ "$id_path" == *":"* ]]; then
  937. id=${id_path%%:*}
  938. path=${id_path#*:}
  939. else
  940. id="$id_path"
  941. path=
  942. fi
  943. BACKUP_TARGET=${BACKUP_ID%#*}
  944. admin=${BACKUP_TARGET%%@*}
  945. server=${BACKUP_TARGET#*@}
  946. ssh_options=()
  947. if [[ "$server" == *":"* ]]; then
  948. ssh_options+=(-p "${server#*:}")
  949. ssh_server=${server%%:*}
  950. fi
  951. BACKUP_PATH="/srv/datastore/data/rsync-backup-target/var/mirror"
  952. if ! content=$(ssh:run "$admin"@"${ssh_server}" "${ssh_options[@]}" -- ssh-key ls 2>/dev/null); then
  953. err "Access denied to '$admin@${server}'."
  954. return 1
  955. fi
  956. idents=$(echo "$content" | sed -r "s/"$'\e'"\[[0-9]+(;[0-9]+)*m//g" | cut -f 2 -d " ")
  957. if ! [[ $'\n'"$idents"$'\n' == *$'\n'"$id"$'\n'* ]]; then
  958. err "Given backup id '$id' not found in $admin@${server}'s idents."
  959. return 1
  960. fi
  961. rtype=$(ssh:run "$admin"@"${ssh_server}" "${ssh_options[@]}" -- ssh-key get-type "$id" ) &&
  962. info "Backup archive matches ${WHITE}${rtype}${NORMAL} type"
  963. p0 "$admin" "$server" "$id" "$path" "$rtype" "$opt_force" |
  964. vps_backup_recover "${VPS_PATH}"
  965. }
  966. cmdline.spec.gnu vps-update
  967. cmdline.spec::cmd:vps-update:run() {
  968. : :posarg: [VPS...] 'Target host to check'
  969. echo "" |
  970. vps_mux vps_update "${VPS[@]}"
  971. }
  972. cmdline.spec.gnu vps-mux
  973. cmdline.spec::cmd:vps-mux:run() {
  974. : :posarg: [VPS...] 'Target host(s) to check'
  975. cat | vps_mux vps_bash "${VPS[@]}"
  976. }
  977. cmdline.spec.gnu vps-space
  978. cmdline.spec::cmd:vps-space:run() {
  979. : :posarg: [VPS...] 'Target host(s) to check'
  980. echo "df /srv -h | tail -n +2 | sed -r 's/ +/ /g' | cut -f 2-5 -d ' '" |
  981. vps_mux vps_bash "${VPS[@]}"
  982. }
  983. cmdline::parse "$@"