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.

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