provisioning tool for building opinionated architecture
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.
 
 

564 lines
20 KiB

#!/bin/bash
usage() {
PREFIX="miaou:usage" echo '<init>'
exit 0
}
yqm() {
#read only
yq "$1" "$EXPANDED_CONF"
}
yqmi() {
# for update
yq "$1" "$EXPANDED_CONF" -i
}
yqmt() {
# tabular
yq "$1" "$EXPANDED_CONF" -o t
}
compute_fqdn_middlepart() {
case "$1" in
prod)
local fqdn_middlepart="."
;;
beta)
local fqdn_middlepart=".beta."
;;
dev)
local fqdn_middlepart=".dev."
;;
*)
echowarn "unknown target <${target}>, please fix with correct value from {prod, beta, dev} and try again..."
exit 1
;;
esac
builtin echo "$fqdn_middlepart"
}
# archive_conf(FILE)
# save patch in archived folder of current file
function archive_conf() {
PREFIX="miaou:conf:archive"
file="$1"
filename=$(basename "$file")
mkdir -p "$MIAOU_CONFIGDIR/archived/$filename"
previous="$MIAOU_CONFIGDIR/archived/$filename/previous"
# shellcheck disable=SC2012
latest_patch=$(ls -1tr "$MIAOU_CONFIGDIR/archived/$filename/" | tail -n1)
if [[ -z "$latest_patch" ]]; then
echo -n "archiving first file <$file> ..."
cp "$file" "$previous"
PREFIX="" echoinfo OK
elif [[ "$file" -nt "$latest_patch" ]]; then
patchname="$MIAOU_CONFIGDIR/archived/$filename/$(date +%F_%T)"
if ! diff "$previous" "$file" >"$patchname"; then
echo -n "archiving patch <$patchname> ..."
cp "$file" "$previous"
PREFIX="" echoinfo OK
else
rm "$patchname"
fi
fi
}
function archive_allconf() {
mkdir -p "$MIAOU_CONFIGDIR"
archive_conf "$CONF"
archive_conf "$DEFAULTS"
}
function check_expand_conf() {
if ! "$FORCE" && [ -f "$EXPANDED_CONF" ] && [ "$EXPANDED_CONF" -nt "$CONF" ] && [ "$EXPANDED_CONF" -nt "$DEFAULTS" ]; then
return 1
fi
}
function expand_conf() {
PREFIX="miaou:conf"
if [[ -f "$EXPANDED_CONF" ]]; then
current_target=$(grep -Es "^target:" /etc/miaou/defaults.yaml | cut -d ' ' -f2)
previous_target=$(grep -Es "^target:" "$EXPANDED_CONF" | cut -d ' ' -f2)
[[ "$current_target" != "$previous_target" ]] && echoerr "TARGET <$previous_target> mismatched <$current_target>" && exit 101
fi
# initialize expanded conf by merging default
# shellcheck disable=SC2016
yq eval-all '. as $item ireduce ({}; . * $item )' "$CONF" "$DEFAULTS" >"$EXPANDED_CONF"
# append unique container unless overridden
mapfile -t services_app_only < <(yqmt '.services.[].[] | has("container") | select ( . == false) | [(parent|key)+" " +key]')
for i in "${services_app_only[@]}"; do
read -r -a item <<<"$i"
domain=${item[0]}
subdomain=${item[1]}
app=$(yqm ".services.\"$domain\".\"$subdomain\".app")
container=$(get_container_for_domain_subdomain_app "$domain" "$subdomain" "$app")
yqmi ".services.\"$domain\".\"$subdomain\".container=\"$container\""
done
# append enabled=true unless overridden
mapfile -t services_app_only < <(yqmt '.services.[].[] | has("enabled") | select ( . == false) | [(parent|key)+" " +key] | unique ')
# echo "found <${#services_app_only[@]}> enabled services"
for i in "${services_app_only[@]}"; do
read -r -a item <<<"$i"
domain=${item[0]}
subdomain=${item[1]}
yqmi ".services.\"$domain\".\"$subdomain\".enabled=true"
done
# compute fqdn
target=$(yqm '.target')
fqdn_middlepart=$(compute_fqdn_middlepart "$target")
# write fqdn_middlepart
yqmi ".expanded.fqdn_middlepart = \"$fqdn_middlepart\""
# add monitored.containers section
yqmi '.expanded.monitored.containers = ([ .services[] | to_entries | .[] | .value | select (.enabled == true ) | .container ] | unique)'
# add monitored.hosts section
# yqmi '.expanded.monitored.hosts = [( .services[][] | select (.enabled == true ) | {"domain": ( parent | key ), "subdomain": key, "fqdn": key + (parent | parent | parent | .expanded.fqdn_middlepart) + ( parent | key ), "container":.container, "port":.port, "app":.app })]'
# add services section
if [[ ${#services_app_only[@]} -gt 0 ]]; then
yqmi '.expanded.services = [( .services[][] | select (.enabled == true ) | {"domain": ( parent | key ), "subdomain": key, "fqdn": key + (parent | parent | parent | .expanded.fqdn_middlepart) + ( parent | key ), "container":.container, "port":.port, "app":.app, "name": .name // "", "exp": .exp // false })]'
else
yqmi '.expanded.services = []'
fi
# change fqdn when exp:true found
readarray -t services < <(yqmt '.expanded.services[] | [ .[] ]')
index=0
for i in "${services[@]}"; do
read -r -a item <<<"$i"
exp=${item[-1]} # the last item
if [[ "$exp" == true ]]; then
domain=${item[0]}
subdomain=${item[1]}
fqdn=${item[2]}
if [[ $target != beta ]]; then
echowarn "experimental service <$subdomain.exp.$domain> not recommended for target <$target>"
else
echo "experimental service <$subdomain.exp.$domain> detected"
fi
yqmi ".expanded.services[$index].fqdn = \"$subdomain.exp.$domain\""
fi
index=$((index + 1))
done
# add firewall section, bridge_subnet + mail_passthrough if any
bridge_subnet=$(lxc network get lxdbr0 ipv4.address)
yqmi ".firewall.bridge_subnet = \"$bridge_subnet\""
container_mail_passthrough=$(yqm ".firewall.container_mail_passthrough")
}
function build_routes() {
PREFIX="miaou:routes"
mkdir -p "$MIAOU_CONFIGDIR/dnsmasq"
mapfile -t fqdns < <(yqm '.expanded.services[].fqdn')
echo "building ${#fqdns[@]} routes..."
raw_dnsmasq=''
for i in "${fqdns[@]}"; do
raw_dnsmasq+="address=/$i/$DMZ_IP\\n"
# append domains to conf
echo "container <$DMZ_CONTAINER.lxd> serves https://$i"
done
builtin echo -e "$raw_dnsmasq" >"$MIAOU_CONFIGDIR/dnsmasq/raw.new"
lxc network get $BRIDGE raw.dnsmasq | head -n-1 >"$MIAOU_CONFIGDIR/dnsmasq/raw.old"
if ! diff -q "$MIAOU_CONFIGDIR/dnsmasq/raw.old" "$MIAOU_CONFIGDIR/dnsmasq/raw.new" &>/dev/null; then
echo -n "new routes detected, reloading dnsmasq + nftables..."
builtin echo -e "$raw_dnsmasq" | lxc network set $BRIDGE raw.dnsmasq -
sudo systemctl reload nftables.service
PREFIX="" echoinfo OK
fi
}
function build_dmz_certbot {
PREFIX="miaou:certbot"
if [[ "$target" != dev ]]; then
#TODO: check public ip available
my_ip=$(dig +short myip.opendns.com @resolver1.opendns.com)
public_hostname=$(hostname -f)
public_ip=$(dig +short A "${public_hostname}" "@${FDN_NAMESERVER}")
if hostname -I | grep -q "$my_ip"; then
echo "My PUBLIC IP address is: <$my_ip>"
echo "My PUBLIC hostname is: <$public_hostname>"
[[ $my_ip != "$public_ip" ]] && echoerr "This machine provides wrong public IP: <$public_ip>" && exit 101
else
echoerr "This machine can not respond to its PUBLIC IP address: <$my_ip>" #FIXME: && exit 100
fi
default_registrar=$(yqm '.registrar.default')
[[ $default_registrar != 'OVH' ]] && echoerr "Sorry, no OVH registrar detected, please provide other registrar protocol" && exit 101
readarray -t services < <(yqmt '.expanded.services[] | [ .domain, .fqdn ]')
for service in "${services[@]}"; do
read -r -a item <<<"$service"
domain=${item[0]}
fqdn=${item[1]}
subDomain=${fqdn%".${domain}"}
echo "TODO: fqdn=${fqdn}, domain=${domain}"
local server_ip
server_ip=$(dig +short A "$fqdn")
if [[ $server_ip == "$my_ip" ]]; then
echo "CNAME <$fqdn> approved successfuly!"
else
if [[ -n $server_ip ]]; then
local server_name
server_name=$(dig +short CNAME "$fqdn")
echowarn "CNAME <$fqdn> points to another server: <$server_name>"
else
echo registering "$domain" "$subDomain" to ... "$public_hostname"
echo NOT YET! #FIXME: to delete
# "$MIAOU_BASEDIR"/lib/registrar/ovh-domain.sh set "$domain" "$subDomain" "$public_hostname"
echo "TODO: TEST with dig, wait for reply in 4s then certbot!!!"
exit 5
fi
fi
done
else
echo "bypass certbot certificate generation due to target=<$target>"
fi
}
function build_dmz_reverseproxy() {
PREFIX="miaou:reverseproxy"
mkdir -p "$MIAOU_CONFIGDIR/nginx"
tera -t "$MIAOU_BASEDIR/templates/nginx/_default.j2" "$EXPANDED_CONF" -o "$MIAOU_CONFIGDIR/nginx/_default" &>/dev/null
tera -t "$MIAOU_BASEDIR/templates/nginx/hosts.j2" "$EXPANDED_CONF" -o "$MIAOU_CONFIGDIR/nginx/hosts" &>/dev/null
for f in "$MIAOU_CONFIGDIR"/nginx/*; do
lxc file push --uid=0 --gid=0 "$f" "$DMZ_CONTAINER/etc/nginx/sites-available/" &>/dev/null
done
cat <<EOF | PREFIX="miaou:build:dmz" lxc_exec "$DMZ_CONTAINER"
cd /etc/nginx/sites-enabled/
for i in ../sites-available/*; do
ln -sf \$i
done
nginx -tq && systemctl restart nginx
EOF
echo "nginx reloaded successfully!"
}
function monit_show() {
PREFIX="monit:show"
: $PREFIX
readarray -t services < <(yqmt '.expanded.services[] | [ .container, .port, .fqdn, .app ]')
echo "======================"
echo "${#services[@]} available services"
echo "======================"
for service in "${services[@]}"; do
read -r -a item <<<"$service"
container=${item[0]}
port=${item[1]}
fqdn=${item[2]}
app=${item[3]}
[[ -n ${PREFIX:-} ]] && printf "${DARK}%25.25s${NC} " "${PREFIX}"
if curl -m $MAX_WAIT -I -4so /dev/null "http://$container:$port"; then
builtin echo -ne "${GREEN}${NC}"
else
builtin echo -ne "${RED}${NC}"
fi
printf "\t%16.16s\thttps://%-40s\thttp://%s\n" "$app" "$fqdn" "$container:$port"
done
}
function build_monit() {
# test whether monitored items actually run safely
PREFIX="monit:build"
readarray -t hosts < <(yqmt '.expanded.services[] | [ .container, .port, .fqdn ]')
echo -n "monitoring ${#hosts[@]} hosts ..."
for host in "${hosts[@]}"; do
read -r -a item <<<"$host"
container=${item[0]}
port=${item[1]}
fqdn=${item[2]}
if ! (lxc exec "$container" -- ss -tln | grep -q "\(0.0.0.0\|*\):$port"); then
PREFIX='' echo
echoerr "no HTTP server responds on <$container.lxd:$port>"
echoerr "please review configuration <miaou.yaml> for fqdn: $fqdn"
exit 2
fi
if ! curl_check_unsecure "https://$fqdn"; then
PREFIX='' echo
echoerr "DMZ does not seem to dispatch https://$fqdn please review DMZ Nginx proxy"
exit 3
elif [[ "$target" != 'dev' ]] && ! curl_check "https://$fqdn"; then
PREFIX='' echo
echowarn "T=$target missing valid certificate for fqdn <https://$fqdn> please review DMZ certbot"
fi
done
# templates for monit
mkdir -p "$MIAOU_CONFIGDIR/monit"
tera -t "$MIAOU_BASEDIR/templates/monit/containers.j2" "$EXPANDED_CONF" -o "$MIAOU_CONFIGDIR/monit/containers" >/dev/null
tera -t "$MIAOU_BASEDIR/templates/monit/hosts.j2" "$EXPANDED_CONF" -o "$MIAOU_CONFIGDIR/monit/hosts" >/dev/null
PREFIX="" echo OK
}
# count_service_for_container(container: string)
# returns how many services run inside container according to expanded conf
function count_service_for_container() {
container_mail_passthrough="$1"
count=$(yqm ".expanded.services.[] | select(.container == \"$container_mail_passthrough\") | .fqdn" | wc -l)
builtin echo "$count"
}
function build_nftables() {
PREFIX="miaou:nftables"
mkdir -p "$MIAOU_CONFIGDIR/nftables.rules.d"
container_mail_passthrough=$(yqm '.firewall.container_mail_passthrough')
if [[ "$container_mail_passthrough" != null ]]; then
ip_mail_passthrough=$(lxc list "$container_mail_passthrough" -c4 -f csv | grep eth0 | cut -d ' ' -f1)
[[ -z "$ip_mail_passthrough" ]] && echoerr "container <$container_mail_passthrough> passthrough unknown ip!" && exit 55
echo "submission protocol granted as passthrough from container <$container_mail_passthrough> ip <$ip_mail_passthrough>"
count=$(count_service_for_container "$container_mail_passthrough")
[[ $count == 0 ]] && echowarn "no service detected => no passthrough, no change!"
[[ $count -gt 1 ]] && echoerr "count <$count> services detected on container <$container_mail_passthrough>, please disable some and leave only one service for safety!!!" && exit 56
ip_mail_passthrough=$ip_mail_passthrough tera -e --env-key env -t "$MIAOU_BASEDIR/templates/nftables/lxd.table.j2" "$EXPANDED_CONF" -o "$MIAOU_CONFIGDIR/nftables.rules.d/lxd.table" &>/dev/null
else
echo "submission protocol prevented from any container"
tera -t "$MIAOU_BASEDIR/templates/nftables/lxd.table.j2" "$EXPANDED_CONF" -o "$MIAOU_CONFIGDIR/nftables.rules.d/lxd.table" &>/dev/null
fi
if ! diff -q "$MIAOU_CONFIGDIR/nftables.rules.d/lxd.table" /etc/nftables.rules.d/lxd.table &>/dev/null; then
sudo_required "reloading nftables"
echo -n "reloading nftables..."
sudo cp "$MIAOU_CONFIGDIR/nftables.rules.d/lxd.table" /etc/nftables.rules.d/lxd.table
sudo systemctl reload nftables
PREFIX="" echo DONE
fi
}
# check whether http server responds something, required <url>, ie: http://example.com:8001, https://example.com
function curl_check() {
arg1_required "$@"
# echo "curl $1"
curl -m $MAX_WAIT -sLI4 "$1" | grep -q "^HTTP.* [2|3|4].*"
}
# check whether https server responds 200 OK, even unsecured certificate (auto-signed in mode DEV)
function curl_check_unsecure() {
arg1_required "$@"
curl -m $MAX_WAIT -skLI4 "$1" | grep -q "^HTTP.* [2|3|4].*"
}
function get_dmz_ip() {
if ! container_running "$DMZ_CONTAINER"; then
echowarn "Container running dmz <$DMZ_CONTAINER> seems down"
echoerr "please \`lxc start $DMZ_CONTAINER\` or initialize first!"
exit 1
fi
dmz_ip=$(host "$DMZ_CONTAINER.lxd" | cut -d ' ' -f4)
if ! valid_ipv4 "$dmz_ip"; then
echowarn "dmz seems up but no valid ip <$dmz_ip> found!"
echoerr "please fix this networking issue, then retry..."
exit 1
else
builtin echo "$dmz_ip"
fi
}
function fetch_container_of_type() {
local type="$1"
readarray -t dmzs < <(yqm ".containers.[].[] | select(.==\"$type\") | parent | key")
case ${#dmzs[@]} in
0) : ;;
1) builtin echo "${dmzs[0]}" ;;
*) for d in "${dmzs[@]}"; do
builtin echo "$d"
done ;;
esac
}
function get_container_for_domain_subdomain_app() {
local domain="$1"
local subdomain="$2"
local app="$3"
readarray -t containers < <(fetch_container_of_type "$app")
case ${#containers[@]} in
0) echoerr "no container of type <$app> found amongst containers for $subdomain.$domain\nHINT : Please, either :\n1. define at least one container for recipe <$app>\n2. remove all services related to recipe <$app>" && exit 1 ;;
1) builtin echo "${containers[0]}" ;;
*)
for d in "${containers[@]}"; do
echowarn "container of type $app found in <$d>"
done
echoerr "multiple containers (${#containers[@]}) provided same app <$app>, therefore container is mandatory alongside $subdomain.$domain" && exit 2
;;
esac
}
function get_unique_container_dmz() {
readarray -t containers < <(fetch_container_of_type "dmz")
case ${#containers[@]} in
0) echoerr "no container of type <dmz> found amongst containers" && exit 1 ;;
1) builtin echo "${containers[0]}" ;;
*)
for d in "${containers[@]}"; do
echowarn "container of type dmz found in <$d>"
done
echoerr "multiple dmz (${#containers[@]}) are not allowed, please select only one " && exit 2
;;
esac
}
function prepare_dmz_container() {
"$MIAOU_BASEDIR"/recipes/dmz/install.sh "$DMZ_CONTAINER"
}
function check_resolv_conf() {
local bridge_gw resolver
bridge_gw=$(lxc network get lxdbr0 ipv4.address | cut -d'/' -f1)
resolver=$(grep nameserver /etc/resolv.conf | head -n1 | cut -d ' ' -f2)
[[ "$bridge_gw" != "$resolver" ]] && return 21
PREFIX="miaou:resolver" echo "nameserver for containers is <$resolver>"
return 0
}
function prepare_containers() {
PREFIX="miaou:containers"
readarray -t containers < <(yqmt ".containers.[] | [ key, .[] ] ")
echo "preparing ${#containers[@]} containers for recipes..."
recipe_count=0
for i in "${containers[@]}"; do
read -r -a item <<<"$i"
container=${item[0]}
for ((j = 1; j < ${#item[@]}; j++)); do
recipe_count=$((recipe_count + 1))
service="${item[$j]}"
recipe_install="$MIAOU_BASEDIR/recipes/$service/install.sh"
if [[ -f "$recipe_install" ]]; then
"$recipe_install" "$container"
else
echoerr "FAILURE, for container <$container>, install recipe [$service] not found!"
echoerr "please review configuration, mismatch recipe name maybe?"
exit 50
fi
echo "container <$container> accepts recipe [$service]"
done
done
echo "approved ${#containers[@]} containers ready to accept ${recipe_count} recipes"
}
function build_services() {
PREFIX="miaou:services"
readarray -t services < <(yqmt '.expanded.services[] | [ .[] ]')
echo "building ${#services[@]} services..."
for i in "${services[@]}"; do
read -r -a item <<<"$i"
fqdn=${item[2]}
container=${item[3]}
port=${item[4]}
app=${item[5]}
name=${item[6]:-}
domain=${item[0]}
subdomain=${item[1]}
data=$(yqm ".services.\"$domain\".\"$subdomain\".data|explode(.)")
recipe="$MIAOU_BASEDIR/recipes/$app/crud.sh"
if [[ -f "$recipe" ]]; then
echo "read [$app:$name] provided by container <$container>"
command="env MIAOU_BASEDIR=$MIAOU_BASEDIR MIAOU_CONFIGDIR=$MIAOU_CONFIGDIR $recipe --port $port --container $container --name $name --fqdn $fqdn --domain $domain --subdomain $subdomain"
[[ "$data" != "null" ]] && command+=" --data \"$data\""
if ! eval "$command -r" >/dev/null; then
echoinfo "CREATE RECIPE"
# echo "$command -c"
eval "$command -c"
echoinfo "CREATE RECIPE: OK"
fi
else
echowarn "for container <$container>, crud recipe [$app] not found!"
fi
done
}
### MAIN
. "$MIAOU_BASEDIR/lib/init.sh"
readonly CONF="/etc/miaou/miaou.yaml"
readonly DEFAULTS="/etc/miaou/defaults.yaml"
readonly EXPANDED_CONF="$MIAOU_CONFIGDIR/miaou.expanded.yaml"
readonly BRIDGE="lxdbr0"
readonly MAX_WAIT=5 # timeout in seconds
# shellcheck disable=SC2034
declare -a options=("$@")
FORCE=false
if containsElement options "-f" || containsElement options "--force"; then
FORCE=true
fi
if containsElement options "history"; then
echo "TODO: HISTORY"
exit 0
fi
if containsElement options "config"; then
editor /etc/miaou/miaou.yaml
if diff -q /etc/miaou/miaou.yaml "$HOME"/.config/miaou/archived/miaou.yaml/previous; then
exit 0
fi
fi
if check_expand_conf; then
archive_allconf
expand_conf
check_resolv_conf
build_nftables
prepare_containers
DMZ_CONTAINER=$(get_unique_container_dmz)
build_dmz_reverseproxy
DMZ_IP=$(get_dmz_ip)
build_services
build_dmz_certbot
build_routes
build_monit
else
monit_show
fi