#!/usr/bin/env bash # Shared functions for the google-auth dokku plugin. set -eo pipefail [[ $DOKKU_TRACE ]] && set -x GOOGLE_AUTH_PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export DOKKU_ROOT=${DOKKU_ROOT:-/home/dokku} export DOKKU_LIB_ROOT=${DOKKU_LIB_ROOT:-/var/lib/dokku} export PLUGIN_CORE_AVAILABLE_PATH=${PLUGIN_CORE_AVAILABLE_PATH:-$DOKKU_LIB_ROOT/core-plugins/available} if [[ -f "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" ]]; then # shellcheck disable=SC1091 source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions" fi GOOGLE_AUTH_DATA_ROOT="$DOKKU_LIB_ROOT/data/google-auth" GOOGLE_AUTH_SERVICE_NAME="dokku-google-auth" GOOGLE_AUTH_IMAGE="dokku-google-auth:latest" GOOGLE_AUTH_DEFAULT_PORT="2999" GOOGLE_AUTH_ROUTE_PREFIX="/_google-auth" # Where the per-app data directory is bind-mounted inside the service container. GOOGLE_AUTH_APP_CONFIG_MOUNT="/data/apps" # Fallbacks so the plugin can be exercised outside a dokku host (tests, dev). if ! declare -f dokku_log_info1 >/dev/null 2>&1; then dokku_log_info1() { echo "-----> $*"; } dokku_log_info2() { echo "=====> $*"; } dokku_log_verbose() { echo " $*"; } dokku_log_warn() { echo " ! $*" 1>&2; } dokku_log_fail() { echo " ! $*" 1>&2 exit 1 } verify_app_name() { [[ -n "$1" && -d "$DOKKU_ROOT/$1" ]] || dokku_log_fail "App $1 does not exist" } fi # --- key/value storage (one file per key under the plugin data dir) --- fn-ga-global-get() { declare KEY="$1" DEFAULT="${2:-}" local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY" if [[ -s "$file" ]]; then head -n1 "$file" else printf '%s' "$DEFAULT" fi } fn-ga-global-set() { declare KEY="$1" VALUE="$2" mkdir -p "$GOOGLE_AUTH_DATA_ROOT/global" printf '%s\n' "$VALUE" >"$GOOGLE_AUTH_DATA_ROOT/global/$KEY" chmod 600 "$GOOGLE_AUTH_DATA_ROOT/global/$KEY" } # Multi-value keys store one entry per line. fn-ga-global-get-list() { declare KEY="$1" cat "$GOOGLE_AUTH_DATA_ROOT/global/$KEY" 2>/dev/null || true } fn-ga-global-set-list() { declare KEY="$1" shift mkdir -p "$GOOGLE_AUTH_DATA_ROOT/global" local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY" : >"$file" local entry for entry in "$@"; do printf '%s\n' "$entry" >>"$file" done chmod 600 "$file" } # --- access lists, scoped to "global" or to one app --- # # An app's allow entries replace the global ones for that app; deny entries from # both scopes are combined. The auth service implements that precedence — these # helpers only store the entries. fn-ga-list-file() { declare SCOPE="$1" KEY="$2" if [[ "$SCOPE" == "global" ]]; then echo "$GOOGLE_AUTH_DATA_ROOT/global/$KEY" else echo "$(fn-ga-app-dir "$SCOPE")/$KEY" fi } fn-ga-list-get() { declare SCOPE="$1" KEY="$2" cat "$(fn-ga-list-file "$SCOPE" "$KEY")" 2>/dev/null || true } fn-ga-list-count() { declare SCOPE="$1" KEY="$2" fn-ga-list-get "$SCOPE" "$KEY" | grep -c . || true } fn-ga-list-contains() { declare SCOPE="$1" KEY="$2" VALUE="$3" grep -qxF "$VALUE" "$(fn-ga-list-file "$SCOPE" "$KEY")" 2>/dev/null } fn-ga-list-add() { declare SCOPE="$1" KEY="$2" VALUE="$3" local file file="$(fn-ga-list-file "$SCOPE" "$KEY")" if [[ "$SCOPE" == "global" ]]; then mkdir -p "$GOOGLE_AUTH_DATA_ROOT/global" touch "$file" chmod 600 "$file" else fn-ga-app-dir-ensure "$SCOPE" >/dev/null touch "$file" # Readable through the service's read-only bind mount; see fn-ga-app-dir-ensure. chmod 644 "$file" fi grep -qxF "$VALUE" "$file" || printf '%s\n' "$VALUE" >>"$file" } fn-ga-list-remove() { declare SCOPE="$1" KEY="$2" VALUE="$3" local file tmp file="$(fn-ga-list-file "$SCOPE" "$KEY")" [[ -f "$file" ]] || return 0 tmp="$(mktemp)" grep -vxF "$VALUE" "$file" >"$tmp" || true cat "$tmp" >"$file" rm -f "$tmp" } # Sets GA_SCOPE from a command's first argument: "global" for --global, # otherwise a verified app name. Fails (and exits) on anything else. fn-ga-resolve-scope() { declare CMD="$1" ARG="${2:-}" case "$ARG" in --global) GA_SCOPE=global ;; "") dokku_log_fail "usage: dokku $CMD |--global [...]" ;; -*) dokku_log_fail "unknown flag '$ARG' — pass an app name or --global" ;; *) verify_app_name "$ARG" GA_SCOPE="$ARG" ;; esac } # Human-readable scope for log lines: "globally" or "for my-app". fn-ga-scope-label() { declare SCOPE="$1" if [[ "$SCOPE" == "global" ]]; then echo "globally" else echo "for $SCOPE" fi } # The scope as it is typed on the command line, for suggested commands. fn-ga-scope-arg() { declare SCOPE="$1" if [[ "$SCOPE" == "global" ]]; then echo "--global" else echo "$SCOPE" fi } # Advisory mirror of the service's allow-list precedence (an app's entries # replace the global ones), used only to warn operators. The authority is # emailAllowedFor in internal/authproxy. fn-ga-email-effectively-allowed() { declare SCOPE="$1" EMAIL="$2" local effective="$SCOPE" if [[ "$SCOPE" != "global" ]] && [[ "$(fn-ga-list-count "$SCOPE" allowed-domains)" -eq 0 && "$(fn-ga-list-count "$SCOPE" allowed-emails)" -eq 0 ]]; then effective=global fi fn-ga-list-contains "$effective" allowed-emails "$EMAIL" && return 0 fn-ga-list-contains "$effective" allowed-domains "${EMAIL##*@}" } # Makes a list change take effect. Global lists travel in the container's # environment and need a restart; per-app lists are read live through the bind # mount, so they only need the mount to actually be there. fn-ga-apply-list-change() { declare SCOPE="$1" if [[ "$SCOPE" == "global" ]]; then fn-ga-reload-service-config return 0 fi # An app's lists only apply if its nginx config tells the service which app a # request belongs to. Configs written before that header existed would make # the app fall back to the global lists, so refresh it here rather than wait # for the next deploy. This is a no-op when the config is already current. if fn-google-auth-app-enabled "$SCOPE"; then fn-ga-apply "$SCOPE" fi if ! fn-ga-service-running; then dokku_log_verbose "auth service is not running; changes apply when it starts" return 0 fi if ! fn-ga-service-reads-app-lists; then dokku_log_info1 "recreating the auth service so it can read per-app lists (this rebuilds the image if the plugin was updated)" fn-ga-service-start return 0 fi dokku_log_verbose "in effect within a few seconds (no restart needed)" } # Entries end up in a comma/space separated env var, so they may contain # neither. fn-ga-validate-list-entry() { declare ENTRY="$1" [[ -n "$ENTRY" ]] || return 1 ! printf '%s' "$ENTRY" | grep -qE '[,[:space:]]' } # "signal.org" and "@signal.org" name a domain; "guest@partner.com" names one # address. Callers strip any leading "@" themselves. fn-ga-allow-entry-kind() { declare ENTRY="$1" if [[ "$ENTRY" == @* || "$ENTRY" != *@* ]]; then echo domain else echo email fi } # The service reads the allow/deny lists from its env file at startup, so # changing them means recreating the container. fn-ga-reload-service-config() { if fn-ga-service-running; then fn-ga-service-start else dokku_log_verbose "auth service is not running; changes apply when it starts" fi } fn-ga-app-dir() { declare APP="$1" echo "$GOOGLE_AUTH_DATA_ROOT/apps/$APP" } # Creates an app's state directory with modes the auth service can use. It reads # per-app lists through a read-only bind mount as an unprivileged uid, so the # directories must be traversable and the list files readable. Nothing is # exposed to other users on the host: $GOOGLE_AUTH_DATA_ROOT itself stays 0700, # and secrets live in global/, which is never mounted. fn-ga-app-dir-ensure() { declare APP="$1" local dir dir="$(fn-ga-app-dir "$APP")" mkdir -p "$dir" chmod 711 "$GOOGLE_AUTH_DATA_ROOT/apps" "$dir" 2>/dev/null || true printf '%s' "$dir" } fn-google-auth-app-enabled() { declare APP="$1" [[ -f "$(fn-ga-app-dir "$APP")/enabled" ]] } fn-ga-app-set-enabled() { declare APP="$1" ENABLED="$2" local dir dir="$(fn-ga-app-dir "$APP")" if [[ "$ENABLED" == "true" ]]; then dir="$(fn-ga-app-dir-ensure "$APP")" touch "$dir/enabled" else rm -f "$dir/enabled" fi } fn-ga-excludes() { declare APP="$1" cat "$(fn-ga-app-dir "$APP")/excludes" 2>/dev/null || true } fn-ga-enabled-apps() { local dir for dir in "$GOOGLE_AUTH_DATA_ROOT/apps"/*/; do [[ -d "$dir" ]] || continue local app app="$(basename "$dir")" fn-google-auth-app-enabled "$app" && echo "$app" done return 0 } fn-ga-configured() { [[ -n "$(fn-ga-global-get client-id)" ]] || return 1 [[ -n "$(fn-ga-global-get client-secret)" ]] || return 1 [[ -n "$(fn-ga-global-get auth-host)" ]] || return 1 [[ -n "$(fn-ga-global-get cookie-secret)" ]] || return 1 [[ -s "$GOOGLE_AUTH_DATA_ROOT/global/allowed-domains" || -s "$GOOGLE_AUTH_DATA_ROOT/global/allowed-emails" ]] || return 1 } # --- exclusion patterns --- # Patterns are either a path prefix ("/api/webhooks") or a regex ("re:^/v[0-9]+/public/"). fn-ga-validate-pattern() { declare PATTERN="$1" [[ -n "$PATTERN" ]] || return 1 # Guard against nginx config injection. if printf '%s' "$PATTERN" | grep -qE '[;{}"'"'"'[:space:]]'; then return 1 fi if [[ "$PATTERN" == re:* ]]; then [[ -n "${PATTERN#re:}" ]] || return 1 else [[ "$PATTERN" == /* ]] || return 1 fi } fn-ga-exclude-add() { declare APP="$1" PATTERN="$2" local dir file dir="$(fn-ga-app-dir-ensure "$APP")" file="$dir/excludes" touch "$file" grep -qxF "$PATTERN" "$file" || printf '%s\n' "$PATTERN" >>"$file" } fn-ga-exclude-remove() { declare APP="$1" PATTERN="$2" local file tmp file="$(fn-ga-app-dir "$APP")/excludes" [[ -f "$file" ]] || return 0 tmp="$(mktemp)" grep -vxF "$PATTERN" "$file" >"$tmp" || true cat "$tmp" >"$file" rm -f "$tmp" } # --- nginx config generation --- # The app's upstream block is created by dokku's own nginx template; we reuse # it by name so excluded and protected locations proxy to the same place. fn-ga-upstream-name() { declare APP="$1" local nginx_conf="$DOKKU_ROOT/$APP/nginx.conf" [[ -f "$nginx_conf" ]] || return 1 local name name="$(awk '$1 == "upstream" {print $2; exit}' "$nginx_conf")" [[ -n "$name" ]] || return 1 printf '%s' "$name" } fn-ga-proxy-read-timeout() { declare APP="$1" local t="" file for file in "$DOKKU_LIB_ROOT/config/nginx/$APP/proxy-read-timeout" \ "$DOKKU_LIB_ROOT/config/nginx/--global/proxy-read-timeout"; do if [[ -s "$file" ]]; then t="$(head -n1 "$file")" break fi done printf '%s' "${t:-60s}" } # Mirrors the proxy directives from dokku's default nginx template so # requests routed through our locations behave like stock dokku routing. fn-ga-proxy-directives() { declare UPSTREAM="$1" TIMEOUT="$2" cat <"$tmp" 2>/dev/null; then rm -f "$tmp" echo skipped return 0 fi mkdir -p "$dir" if [[ -f "$conf" ]] && cmp -s "$tmp" "$conf"; then rm -f "$tmp" echo unchanged return 0 fi cat "$tmp" >"$conf" rm -f "$tmp" echo changed } # --- nginx validate/reload (via dokku core helpers when available) --- fn-ga-source-nginx-functions() { if [[ -f "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/functions" ]]; then # shellcheck disable=SC1091 source "$PLUGIN_CORE_AVAILABLE_PATH/nginx-vhosts/functions" 2>/dev/null || true fi } fn-ga-nginx-validate() { fn-ga-source-nginx-functions if declare -f validate_nginx >/dev/null 2>&1; then (validate_nginx) >/dev/null 2>&1 return $? fi return 0 } fn-ga-nginx-reload() { fn-ga-source-nginx-functions if declare -f restart_nginx >/dev/null 2>&1; then restart_nginx >/dev/null 2>&1 || dokku_log_warn "nginx reload reported an error; check 'nginx -t'" else dokku_log_warn "could not reload nginx automatically; run: sudo systemctl reload nginx" fi } # Write conf for one app, validate nginx, roll back on failure, reload. fn-ga-apply() { declare APP="$1" local conf backup="" had_file=false status conf="$(fn-ga-conf-path "$APP")" if [[ -f "$conf" ]]; then backup="$(mktemp)" cat "$conf" >"$backup" had_file=true fi status="$(fn-ga-write-conf "$APP")" case "$status" in skipped) dokku_log_warn "$APP has no generated nginx config yet (not deployed?). google-auth config will be added on the next deploy." ;; changed) if ! fn-ga-nginx-validate; then if [[ "$had_file" == "true" ]]; then cat "$backup" >"$conf" else rm -f "$conf" fi [[ -n "$backup" ]] && rm -f "$backup" dokku_log_fail "nginx rejected the generated config for $APP; change reverted (check exclude patterns)" fi fn-ga-nginx-reload ;; esac [[ -n "$backup" ]] && rm -f "$backup" return 0 } # Regenerate confs for every enabled app (e.g. after the service port changes). fn-ga-apply-all() { local app any_changed=false status while IFS= read -r app; do [[ -z "$app" ]] && continue status="$(fn-ga-write-conf "$app")" [[ "$status" == "changed" ]] && any_changed=true done < <(fn-ga-enabled-apps) if [[ "$any_changed" == "true" ]]; then if fn-ga-nginx-validate; then fn-ga-nginx-reload else dokku_log_warn "nginx validation failed after regenerating google-auth configs; run 'nginx -t' to inspect" fi fi return 0 } # --- auth service container management --- fn-ga-image-exists() { docker image inspect "$GOOGLE_AUTH_IMAGE" >/dev/null 2>&1 } # Fingerprint of everything that ends up in the service image. The image is # labelled with it so an upgraded plugin rebuilds instead of reusing a binary # built from older source. Without this the shell half of the plugin upgrades # the moment the files change — new commands, new nginx config, new env var — # while the binary enforcing them stays whatever was built first, and the # mismatch is invisible from the outside. fn-ga-source-hash() { local dir="$GOOGLE_AUTH_PLUGIN_DIR" file { while IFS= read -r file; do printf '%s ' "${file#"$dir/"}" sha256sum "$file" | awk '{print $1}' done < <( { printf '%s\n' "$dir/Dockerfile" "$dir/go.mod" find "$dir/cmd" "$dir/internal" -type f 2>/dev/null } | LC_ALL=C sort ) } | sha256sum | awk '{print $1}' } # True when the built image matches the plugin's current source. fn-ga-image-current() { fn-ga-image-exists || return 1 local labelled labelled="$(docker image inspect -f '{{index .Config.Labels "google-auth.source-hash"}}' "$GOOGLE_AUTH_IMAGE" 2>/dev/null)" # Images built before this label existed report an empty value or "", # and are stale by definition. [[ -n "$labelled" && "$labelled" != "" ]] || return 1 [[ "$labelled" == "$(fn-ga-source-hash)" ]] } fn-ga-build-image() { command -v docker >/dev/null 2>&1 || dokku_log_fail "docker is required to build the google-auth service image" dokku_log_info1 "Building $GOOGLE_AUTH_IMAGE (first build downloads the Go toolchain image; this can take a few minutes)" docker image build \ --label "google-auth.source-hash=$(fn-ga-source-hash)" \ -t "$GOOGLE_AUTH_IMAGE" "$GOOGLE_AUTH_PLUGIN_DIR" } fn-ga-service-running() { [[ "$(docker container inspect -f '{{.State.Running}}' "$GOOGLE_AUTH_SERVICE_NAME" 2>/dev/null)" == "true" ]] } # Asks the running service which per-app config directory it is using, and # prints it. This is the only check that sees the binary rather than the # container around it; a build from before per-app lists existed answers with a # bare "ok" and names no directory. Fails if the service is unreachable or too # old to answer. fn-ga-service-app-config-dir() { command -v curl >/dev/null 2>&1 || return 1 local port body dir port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")" body="$(curl -fsS --max-time 3 "http://127.0.0.1:${port}${GOOGLE_AUTH_ROUTE_PREFIX}/healthz" 2>/dev/null)" || return 1 dir="$(sed -n 's/.*"app_config_dir":"\([^"]*\)".*/\1/p' <<<"$body")" [[ -n "$dir" ]] || return 1 printf '%s' "$dir" } # True when the running service can actually read per-app lists. That takes # three things, and missing any of them makes the service ignore every per-app # list and fall back to the global one — silently, and in the permissive # direction — so all three are checked: # # the bind mount — a container from an older plugin has none; # the env var naming it — one recreated from a stale service.env has the # mount without the variable; # a binary that reads both — one recreated from a stale *image* has the # mount and the variable and ignores them, which # no amount of docker metadata can reveal. Only # the service's own answer distinguishes it. fn-ga-service-reads-app-lists() { local inspected inspected="$(docker container inspect \ -f '{{range .Mounts}}mount={{println .Destination}}{{end}}{{range .Config.Env}}env={{println .}}{{end}}' \ "$GOOGLE_AUTH_SERVICE_NAME" 2>/dev/null)" || return 1 grep -qxF "mount=$GOOGLE_AUTH_APP_CONFIG_MOUNT" <<<"$inspected" || return 1 grep -qxF "env=GOOGLE_AUTH_APP_CONFIG_DIR=$GOOGLE_AUTH_APP_CONFIG_MOUNT" <<<"$inspected" || return 1 local reported if reported="$(fn-ga-service-app-config-dir)"; then [[ "$reported" == "$GOOGLE_AUTH_APP_CONFIG_MOUNT" ]] return $? fi # No answer (curl missing, or the service is not reachable on the loopback # port): fall back to checking that the image was built from this plugin's # current source and that the container is running that image. fn-ga-image-current && fn-ga-container-runs-current-image } # True when the running container was created from the current service image, # rather than from an earlier build still tagged over. fn-ga-container-runs-current-image() { local running current running="$(docker container inspect -f '{{.Image}}' "$GOOGLE_AUTH_SERVICE_NAME" 2>/dev/null)" || return 1 current="$(docker image inspect -f '{{.Id}}' "$GOOGLE_AUTH_IMAGE" 2>/dev/null)" || return 1 [[ -n "$running" && "$running" == "$current" ]] } fn-ga-write-env-file() { local envfile="$GOOGLE_AUTH_DATA_ROOT/service.env" local domains emails denied domains="$(fn-ga-global-get-list allowed-domains | paste -sd, -)" emails="$(fn-ga-global-get-list allowed-emails | paste -sd, -)" denied="$(fn-ga-global-get-list denied-emails | paste -sd, -)" mkdir -p "$GOOGLE_AUTH_DATA_ROOT" # The umask stays inside this subshell. Leaking it left every directory # created later in the same command at 0700 — including an app's config # directory during google-auth:enable, which the service then could not # traverse to read that app's lists. ( umask 077 cat >"$envfile" </dev/null || true docker container rm -f "$GOOGLE_AUTH_SERVICE_NAME" >/dev/null 2>&1 || true docker container run -d \ --name "$GOOGLE_AUTH_SERVICE_NAME" \ --restart=unless-stopped \ -p "127.0.0.1:${port}:2999" \ --env-file "$GOOGLE_AUTH_DATA_ROOT/service.env" \ -v "$GOOGLE_AUTH_DATA_ROOT/apps:${GOOGLE_AUTH_APP_CONFIG_MOUNT}:ro" \ "$GOOGLE_AUTH_IMAGE" >/dev/null dokku_log_info1 "google-auth service running on 127.0.0.1:${port}" } fn-ga-service-stop() { docker container rm -f "$GOOGLE_AUTH_SERVICE_NAME" >/dev/null 2>&1 || true } # Warn when the configured auth host is not served by any enabled app, # because Google's callback would then land on nothing. fn-ga-warn-if-auth-host-unrouted() { local auth_host app vhost_file auth_host="$(fn-ga-global-get auth-host)" [[ -n "$auth_host" ]] || return 0 while IFS= read -r app; do [[ -z "$app" ]] && continue vhost_file="$DOKKU_ROOT/$app/VHOST" [[ -f "$vhost_file" ]] && grep -qxF "$auth_host" "$vhost_file" && return 0 done < <(fn-ga-enabled-apps) dokku_log_warn "auth host '$auth_host' is not a domain of any google-auth-enabled app." dokku_log_warn "Google's OAuth callback (https://$auth_host$GOOGLE_AUTH_ROUTE_PREFIX/callback) must route to an enabled app." return 0 }