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.

599 lines
16 KiB

  1. #!/bin/bash
  2. . /etc/shlib
  3. include common
  4. include parse
  5. include cmdline
  6. include config
  7. include cache
  8. include fn
  9. [[ "${BASH_SOURCE[0]}" != "${0}" ]] && SOURCED=true
  10. version=0.1
  11. desc='Install backup'
  12. help=""
  13. docker:running-container-projects() {
  14. :cache: scope=session
  15. docker ps --format '{{.Label "com.docker.compose.project"}}' | sort | uniq
  16. }
  17. decorator._mangle_fn docker:running-container-projects
  18. ssh:mk-private-key() {
  19. local host="$1" service_name="$2"
  20. (
  21. settmpdir VPS_TMPDIR
  22. ssh-keygen -t rsa -N "" -f "$VPS_TMPDIR/rsync_rsa" -C "$service_name@$host" >/dev/null
  23. cat "$VPS_TMPDIR/rsync_rsa"
  24. )
  25. }
  26. mailcow:has-images-running() {
  27. local images
  28. images=$(docker ps --format '{{.Image}}' | sort | uniq)
  29. [[ $'\n'"$images" == *$'\n'"mailcow/"* ]]
  30. }
  31. mailcow:has-container-project-mentionning-mailcow() {
  32. local projects
  33. projects=$(docker:running-container-projects) || return 1
  34. [[ $'\n'"$projects"$'\n' == *mailcow* ]]
  35. }
  36. mailcow:has-running-containers() {
  37. mailcow:has-images-running ||
  38. mailcow:has-container-project-mentionning-mailcow
  39. }
  40. mailcow:get-root() {
  41. :cache: scope=session
  42. local dir
  43. for dir in {/opt{,/apps},/root}/mailcow-dockerized; do
  44. [ -d "$dir" ] || continue
  45. [ -r "$dir/mailcow.conf" ] || continue
  46. echo "$dir"
  47. return 0
  48. done
  49. return 1
  50. }
  51. decorator._mangle_fn mailcow:get-root
  52. compose:get-compose-yml() {
  53. :cache: scope=session
  54. local path
  55. [ -e "/etc/compose/local.conf" ] && . "/etc/compose/local.conf"
  56. path=${DEFAULT_COMPOSE_FILE:-/etc/compose/compose.yml}
  57. [ -e "$path" ] || return 1
  58. echo "$path"
  59. }
  60. decorator._mangle_fn compose:get-compose-yml
  61. compose:has-container-project-myc() {
  62. local projects
  63. projects=$(docker:running-container-projects) || return 1
  64. [[ $'\n'"$projects"$'\n' == *$'\n'"myc"$'\n'* ]]
  65. }
  66. type:is-mailcow() {
  67. mailcow:get-root >/dev/null ||
  68. mailcow:has-running-containers
  69. }
  70. type:is-compose() {
  71. compose:get-compose-yml >/dev/null &&
  72. compose:has-container-project-myc
  73. }
  74. vps:get-type() {
  75. local fn
  76. for fn in $(declare -F | cut -f 3 -d " " | egrep "^type:is-"); do
  77. "$fn" && {
  78. echo "${fn#type:is-}"
  79. return 0
  80. }
  81. done
  82. return 1
  83. }
  84. mirror-dir:sources() {
  85. :cache: scope=session
  86. if ! shyaml get-values default.sources < /etc/mirror-dir/config.yml; then
  87. err "Couldn't query 'default.sources' in '/etc/mirror-dir/config.yml'."
  88. return 1
  89. fi
  90. }
  91. decorator._mangle_fn mirror-dir:sources
  92. mirror-dir:check-add() {
  93. local elt="$1" sources
  94. sources=$(mirror-dir:sources) || return 1
  95. if [[ $'\n'"$sources"$'\n' == *$'\n'"$elt"$'\n'* ]]; then
  96. info "Volume $elt already in sources"
  97. else
  98. Elt "Adding directory $elt"
  99. sed -i "/sources:/a\ - \"${elt}\"" \
  100. /etc/mirror-dir/config.yml
  101. Feedback || return 1
  102. fi
  103. }
  104. mirror-dir:check-add-vol() {
  105. local elt="$1"
  106. mirror-dir:check-add "/var/lib/docker/volumes/*_${elt}-*/_data"
  107. }
  108. ## The first colon is to prevent auto-export of function from shlib
  109. : ; bash-bug-5() { { cat; } < <(e) >/dev/null; ! cat "$1"; } && bash-bug-5 <(e) 2>/dev/null &&
  110. export BASH_BUG_5=1 && unset -f bash_bug_5
  111. wrap() {
  112. local label="$1" code="$2"
  113. shift 2
  114. export VERBOSE=1
  115. interpreter=/bin/bash
  116. if [ -n "$BASH_BUG_5" ]; then
  117. (
  118. settmpdir tmpdir
  119. fname=${label##*/}
  120. e "$code" > "$tmpdir/$fname" &&
  121. chmod +x "$tmpdir/$fname" &&
  122. Wrap -vsd "$label" -- "$interpreter" "$tmpdir/$fname" "$@"
  123. )
  124. else
  125. Wrap -vsd "$label" -- "$interpreter" <(e "$code") "$@"
  126. fi
  127. }
  128. ping_check() {
  129. #global ignore_ping_check
  130. local host="$1"
  131. ip=$(getent ahosts "$host" | egrep "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s+" |
  132. head -n 1 | cut -f 1 -d " ") || return 1
  133. my_ip=$(curl -s myip.kal.fr)
  134. if [ "$ip" != "$my_ip" ]; then
  135. if [ -n "$ignore_ping_check" ]; then
  136. warn "IP of '$host' ($ip) doesn't match mine ($my_ip). Ignoring due to ``--ignore-ping-check`` option."
  137. else
  138. err "IP of '$host' ($ip) doesn't match mine ($my_ip). Use ``--ignore-ping-check`` to ignore check."
  139. return 1
  140. fi
  141. fi
  142. }
  143. mailcow:install-backup() {
  144. local BACKUP_SERVER="$1" ignore_ping_check="$2" mailcow_root DOMAIN
  145. ## find installation
  146. mailcow_root=$(mailcow:get-root) || {
  147. err "Couldn't find a valid mailcow root directory."
  148. return 1
  149. }
  150. ## check ok
  151. DOMAIN=$(cat "$mailcow_root/.env" | grep ^MAILCOW_HOSTNAME= | cut -f 2 -d =) || {
  152. err "Couldn't find MAILCOW_HOSTNAME in file \"$mailcow_root/.env\"."
  153. return 1
  154. }
  155. ping_check "$DOMAIN" || return 1
  156. MYSQL_ROOT_PASSWORD=$(cat "$mailcow_root/.env" | grep ^DBROOT= | cut -f 2 -d =) || {
  157. err "Couldn't find DBROOT in file \"$mailcow_root/.env\"."
  158. return 1
  159. }
  160. MYSQL_CONTAINER=${MYSQL_CONTAINER:-mailcowdockerized_mysql-mailcow_1}
  161. container_id=$(docker ps -f name="$MYSQL_CONTAINER" --format "{{.ID}}")
  162. if [ -z "$container_id" ]; then
  163. err "Couldn't find docker container named '$MYSQL_CONTAINER'."
  164. return 1
  165. fi
  166. export MYSQL_ROOT_PASSWORD
  167. export MYSQL_CONTAINER
  168. export BACKUP_SERVER
  169. export DOMAIN
  170. wrap "Install rsync-backup on host" "
  171. cd /srv/charm-store/rsync-backup
  172. bash ./hooks/install.d/60-install.sh
  173. " || return 1
  174. wrap "Mysql dump install" "
  175. cd /srv/charm-store/mariadb
  176. bash ./hooks/install.d/60-backup.sh
  177. " || return 1
  178. ## Using https://github.com/mailcow/mailcow-dockerized/blob/master/helper-scripts/backup_and_restore.sh
  179. for elt in "vmail{,-attachments-vol}" crypt redis rspamd postfix; do
  180. mirror-dir:check-add-vol "$elt" || return 1
  181. done
  182. mirror-dir:check-add "$mailcow_root" || return 1
  183. mirror-dir:check-add "/var/backups/mysql" || return 1
  184. mirror-dir:check-add "/etc" || return 1
  185. dest="$BACKUP_SERVER"
  186. dest="${dest%/*}"
  187. ssh_options=()
  188. if [[ "$dest" == *":"* ]]; then
  189. port="${dest##*:}"
  190. dest="${dest%%:*}"
  191. ssh_options=(-p "$port")
  192. else
  193. port=""
  194. dest="${dest%%:*}"
  195. fi
  196. info "You can run this following command from an host having admin access to $dest:"
  197. echo " (Or send it to a backup admin of $dest)" >&2
  198. echo "ssh ${ssh_options[@]} myadmin@$dest ssh-key add '$(cat /var/lib/rsync/.ssh/id_rsa.pub)'"
  199. }
  200. compose:has_domain() {
  201. local compose_file="$1" host="$2" name conf relation relation_value domain server_aliases
  202. while read-0 name conf ; do
  203. name=$(e "$name" | shyaml get-value)
  204. if [[ "$name" =~ ^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+ ]]; then
  205. [ "$host" == "$name" ] && return 0
  206. fi
  207. rel=$(e "$conf" | shyaml -y get-value relations 2>/dev/null) || continue
  208. for relation in web-proxy publish-dir; do
  209. relation_value=$(e "$rel" | shyaml -y get-value "$relation" 2>/dev/null) || continue
  210. while read-0 label conf_relation; do
  211. domain=$(e "$conf_relation" | shyaml get-value "domain" 2>/dev/null) && {
  212. [ "$host" == "$domain" ] && return 0
  213. }
  214. server_aliases=$(e "$conf_relation" | shyaml get-values "server-aliases" 2>/dev/null) && {
  215. [[ $'\n'"$server_aliases" == *$'\n'"$host"$'\n'* ]] && return 0
  216. }
  217. done < <(e "$relation_value" | shyaml -y key-values-0)
  218. done
  219. done < <(shyaml -y key-values-0 < "$compose_file")
  220. return 1
  221. }
  222. compose:install-backup() {
  223. local BACKUP_SERVER="$1" service_name="$2" compose_file="$3" ignore_ping_check="$4" ignore_domain_check="$5"
  224. ## XXXvlab: far from perfect as it mimics and depends internal
  225. ## logic of current default way to get a domain in compose-core
  226. host=$(hostname)
  227. if ! compose:has_domain "$compose_file" "$host"; then
  228. if [ -n "$ignore_domain_check" ]; then
  229. warn "domain of '$host' not found in compose file '$compose_file'. Ignoring due to ``--ignore-domain-check`` option."
  230. else
  231. err "domain of '$host' not found in compose file '$compose_file'. Use ``--ignore-domain-check`` to ignore check."
  232. return 1
  233. fi
  234. fi
  235. ping_check "$DOMAIN" || return 1
  236. if [ -e "/root/.ssh/rsync_rsa" ]; then
  237. warn "deleting private key in /root/.ssh/rsync_rsa, has we are not using it anymore."
  238. rm -fv /root/.ssh/rsync_rsa
  239. fi
  240. if [ -e "/root/.ssh/rsync_rsa.pub" ]; then
  241. warn "deleting public key in /root/.ssh/rsync_rsa.pub, has we are not using it anymore."
  242. rm -fv /root/.ssh/rsync_rsa.pub
  243. fi
  244. if service_cfg=$(cat "$compose_file" |
  245. shyaml get-value -y "$service_name" 2>/dev/null); then
  246. info "Entry for service ${DARKYELLOW}$service_name${NORMAL}" \
  247. "is already present in '$compose_file'."
  248. cfg=$(e "$service_cfg" | shyaml get-value -y options) || {
  249. err "No ${WHITE}options${NORMAL} in ${DARKYELLOW}$service_name${NORMAL}'s" \
  250. "entry in '$compose_file'."
  251. return 1
  252. }
  253. private_key=$(e "$cfg" | shyaml get-value private-key)
  254. target=$(e "$cfg" | shyaml get-value target)
  255. if [ "$target" != "$BACKUP_SERVER" ]; then
  256. err "Existing backup target '$target' is different" \
  257. "from specified '$BACKUP_SERVER'"
  258. return 1
  259. fi
  260. else
  261. private_key=$(ssh:mk-private-key "$host" "$service_name")
  262. cat <<EOF >> "$compose_file"
  263. $service_name:
  264. options:
  265. ident: $host
  266. target: $BACKUP_SERVER
  267. private-key: |
  268. $(e "$private_key" | sed -r 's/^/ /g')
  269. EOF
  270. fi
  271. dest="$BACKUP_SERVER"
  272. dest="${dest%/*}"
  273. ssh_options=()
  274. if [[ "$dest" == *":"* ]]; then
  275. port="${dest##*:}"
  276. dest="${dest%%:*}"
  277. ssh_options=(-p "$port")
  278. else
  279. port=""
  280. dest="${dest%%:*}"
  281. fi
  282. info "You can run this following command from an host having admin access to $dest:"
  283. echo " (Or send it to a backup admin of $dest)" >&2
  284. public_key=$(ssh-keygen -y -f <(e "$private_key"$'\n'))
  285. echo "ssh ${ssh_options[@]} myadmin@$dest ssh-key add '$public_key ${service_name}@$host'"
  286. }
  287. [ "$SOURCED" ] && return 0
  288. ##
  289. ## Command line processing
  290. ##
  291. cmdline.spec.gnu
  292. cmdline.spec.reporting
  293. cmdline.spec.gnu install
  294. cmdline.spec::cmd:install:run() {
  295. :
  296. }
  297. cmdline.spec.gnu get-type
  298. cmdline.spec::cmd:get-type:run() {
  299. vps:get-type
  300. }
  301. cmdline.spec:install:cmd:backup:run() {
  302. : :posarg: BACKUP_SERVER 'Target backup server'
  303. : :optfla: --ignore-domain-check \
  304. "Allow to bypass the domain check in
  305. compose file (only used in compose
  306. installation)."
  307. : :optfla: --ignore-ping-check "Allow to bypass the ping check of host."
  308. local vps_type
  309. vps_type=$(vps:get-type) || {
  310. err "Failed to get type of installation."
  311. return 1
  312. }
  313. if ! fn.exists "${vps_type}:install-backup"; then
  314. err "type '${vps_type}' has no backup installation implemented yet."
  315. return 1
  316. fi
  317. opts=()
  318. [ "$opt_ignore_ping_check" ] &&
  319. opts+=("--ignore-ping-check")
  320. if [ "$vps_type" == "compose" ]; then
  321. [ "$opt_ignore_domain_check" ] &&
  322. opts+=("--ignore-domain-check")
  323. fi
  324. "cmdline.spec:install:cmd:${vps_type}-backup:run" "${opts[@]}" "$BACKUP_SERVER"
  325. }
  326. DEFAULT_BACKUP_SERVICE_NAME=rsync-backup
  327. cmdline.spec.gnu compose-backup
  328. cmdline.spec:install:cmd:compose-backup:run() {
  329. : :posarg: BACKUP_SERVER 'Target backup server'
  330. : :optval: --service-name,-s "YAML service name in compose
  331. file to check for existence of key.
  332. Defaults to '$DEFAULT_BACKUP_SERVICE_NAME'"
  333. : :optval: --compose-file,-f "Compose file location. Defaults to
  334. the value of '\$DEFAULT_COMPOSE_FILE'"
  335. : :optfla: --ignore-domain-check \
  336. "Allow to bypass the domain check in
  337. compose file."
  338. : :optfla: --ignore-ping-check "Allow to bypass the ping check of host."
  339. local service_name compose_file
  340. [ -e "/etc/compose/local.conf" ] && source /etc/compose/local.conf
  341. compose_file=${opt_compose_file:-$DEFAULT_COMPOSE_FILE}
  342. service_name=${opt_service_name:-$DEFAULT_BACKUP_SERVICE_NAME}
  343. if ! [ -e "$compose_file" ]; then
  344. err "Compose file not found in '$compose_file'."
  345. return 1
  346. fi
  347. compose:install-backup "$BACKUP_SERVER" "$service_name" "$compose_file" \
  348. "$opt_ignore_ping_check" "$opt_ignore_domain_check"
  349. }
  350. cmdline.spec:install:cmd:mailcow-backup:run() {
  351. : :posarg: BACKUP_SERVER 'Target backup server'
  352. : :optfla: --ignore-ping-check "Allow to bypass the ping check of host."
  353. "mailcow:install-backup" "$BACKUP_SERVER" "$opt_ignore_ping_check"
  354. }
  355. cmdline.spec.gnu backup
  356. cmdline.spec::cmd:backup:run() {
  357. local vps_type
  358. vps_type=$(vps:get-type) || {
  359. err "Failed to get type of installation."
  360. return 1
  361. }
  362. if ! fn.exists "cmdline.spec:backup:cmd:${vps_type}:run"; then
  363. err "type '${vps_type}' has no backup process implemented yet."
  364. return 1
  365. fi
  366. "cmdline.spec:backup:cmd:${vps_type}:run"
  367. }
  368. cmdline.spec:backup:cmd:mailcow:run() {
  369. local cmd_line cron_line cmd
  370. for f in mysql-backup mirror-dir; do
  371. [ -e "/etc/cron.d/$f" ] || {
  372. err "Can't find '/etc/cron.d/$f'."
  373. echo " Have you forgotten to run 'vps install backup BACKUP_HOST' ?" >&2
  374. return 1
  375. }
  376. if ! cron_line=$(cat "/etc/cron.d/$f" |
  377. grep -v "^#" | grep "\* \* \*"); then
  378. err "Can't find cron_line in '/etc/cron.d/$f'." \
  379. "Have you modified it ?"
  380. return 1
  381. fi
  382. cron_line=${cron_line%|*}
  383. cmd_line=(${cron_line#*root})
  384. if [ "$f" == "mirror-dir" ]; then
  385. cmd=()
  386. for arg in "${cmd_line[@]}"; do
  387. [ "$arg" != "-q" ] && cmd+=("$arg")
  388. done
  389. else
  390. cmd=("${cmd_line[@]}")
  391. fi
  392. code="${cmd[*]}"
  393. echo "${WHITE}Launching:${NORMAL} ${code}"
  394. {
  395. {
  396. (
  397. ## Some commands are using colors that are already
  398. ## set by this current program and will trickle
  399. ## down unwantedly
  400. ansi_color no
  401. eval "${code}"
  402. ) | sed -r "s/^/ ${GRAY}|${NORMAL} /g"
  403. set_errlvl "${PIPESTATUS[0]}"
  404. } 3>&1 1>&2 2>&3 | sed -r "s/^/ $DARKRED\!$NORMAL /g"
  405. set_errlvl "${PIPESTATUS[0]}"
  406. } 3>&1 1>&2 2>&3
  407. if [ "$?" != "0" ]; then
  408. err "Failed."
  409. return 1
  410. fi
  411. done
  412. info "Mysql backup and subsequent mirror-dir ${DARKGREEN}succeeded${NORMAL}."
  413. }
  414. set_errlvl() { return "${1:-1}"; }
  415. cmdline.spec:backup:cmd:compose:run() {
  416. local cron_line args
  417. if ! cron_line=$(docker exec myc_cron_1 cat /etc/cron.d/rsync-backup | grep "\* \* \*"); then
  418. err "Can't find cron_line in cron container."
  419. echo " Have you forgotten to run 'compose up' ?" >&2
  420. exit 1
  421. fi
  422. cron_line=${cron_line%|*}
  423. cron_line=${cron_line%"2>&1"*}
  424. cmd_line="${cron_line#*root}"
  425. eval "args=($cmd_line)"
  426. ## should be last argument
  427. docker_cmd=$(echo ${args[@]: -1})
  428. if ! [[ "$docker_cmd" == "docker run --rm -e "* ]]; then
  429. echo "docker command found should start with 'docker run'." >&2
  430. echo "Here's command:" >&2
  431. echo " $docker_cmd" >&2
  432. exit 1
  433. fi
  434. echo "${WHITE}Launching:${NORMAL} docker exec -i myc_cron_1 $docker_cmd"
  435. {
  436. {
  437. eval "docker exec -i myc_cron_1 $docker_cmd" | sed -r "s/^/ ${GRAY}|${NORMAL} /g"
  438. set_errlvl "${PIPESTATUS[0]}"
  439. } 3>&1 1>&2 2>&3 | sed -r "s/^/ $DARKRED\!$NORMAL /g"
  440. set_errlvl "${PIPESTATUS[0]}"
  441. } 3>&1 1>&2 2>&3
  442. if [ "$?" != "0" ]; then
  443. err "Failed."
  444. return 1
  445. fi
  446. info "mirror-dir ${DARKGREEN}succeeded${NORMAL}."
  447. }
  448. cmdline::parse "$@"