Files
dokkku-google-auth/functions
T
2026-08-06 22:52:15 -04:00

778 lines
25 KiB
Bash

#!/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 <app>|--global [<entry>...]"
;;
-*)
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 <<EOF
proxy_pass http://${UPSTREAM};
proxy_http_version 1.1;
proxy_read_timeout ${TIMEOUT};
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$http_connection;
proxy_set_header Host \$http_host;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Request-Start \$msec;
EOF
}
fn-ga-generate-conf() {
declare APP="$1"
local upstream timeout port
upstream="$(fn-ga-upstream-name "$APP")" || return 1
timeout="$(fn-ga-proxy-read-timeout "$APP")"
port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")"
cat <<EOF
# Managed by the dokku google-auth plugin — do not edit by hand.
# Regenerated on every deploy and by google-auth:* commands.
# Every location below sets X-Google-Auth-App explicitly, which both tells the
# auth service whose access lists to apply and overwrites any value a client
# tried to send.
location = ${GOOGLE_AUTH_ROUTE_PREFIX}/verify {
internal;
proxy_pass http://127.0.0.1:${port};
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Google-Auth-App "${APP}";
}
location ^~ ${GOOGLE_AUTH_ROUTE_PREFIX}/ {
proxy_pass http://127.0.0.1:${port};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header X-Auth-Request-Redirect "";
proxy_set_header X-Google-Auth-App "${APP}";
}
location @google_auth_signin {
rewrite ^ ${GOOGLE_AUTH_ROUTE_PREFIX}/start break;
proxy_pass http://127.0.0.1:${port};
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header X-Auth-Request-Redirect \$request_uri;
proxy_set_header X-Google-Auth-App "${APP}";
}
EOF
local pattern
while IFS= read -r pattern; do
[[ -z "$pattern" ]] && continue
echo ""
if [[ "$pattern" == re:* ]]; then
echo "# google-auth: path excluded from SSO (${pattern})"
echo "location ~ ${pattern#re:} {"
else
echo "# google-auth: path excluded from SSO (${pattern})"
echo "location ^~ ${pattern} {"
fi
fn-ga-proxy-directives "$upstream" "$timeout"
cat <<'EOF'
# Strip identity headers so clients cannot spoof them on open paths.
proxy_set_header X-Forwarded-User "";
proxy_set_header X-Forwarded-Email "";
proxy_set_header X-Auth-Request-User "";
proxy_set_header X-Auth-Request-Email "";
proxy_set_header X-Auth-Request-Name "";
proxy_set_header X-Google-Auth-App "";
}
EOF
done < <(fn-ga-excludes "$APP")
cat <<EOF
# Everything else requires a Google session.
location ~ ^/ {
auth_request ${GOOGLE_AUTH_ROUTE_PREFIX}/verify;
auth_request_set \$google_auth_user \$upstream_http_x_auth_request_user;
auth_request_set \$google_auth_email \$upstream_http_x_auth_request_email;
auth_request_set \$google_auth_name \$upstream_http_x_auth_request_name;
error_page 401 = @google_auth_signin;
$(fn-ga-proxy-directives "$upstream" "$timeout")
proxy_set_header X-Forwarded-User \$google_auth_user;
proxy_set_header X-Forwarded-Email \$google_auth_email;
proxy_set_header X-Auth-Request-User \$google_auth_user;
proxy_set_header X-Auth-Request-Email \$google_auth_email;
proxy_set_header X-Auth-Request-Name \$google_auth_name;
proxy_set_header X-Google-Auth-App "";
}
EOF
}
fn-ga-conf-path() {
declare APP="$1"
echo "$DOKKU_ROOT/$APP/nginx.conf.d/google-auth.conf"
}
# Regenerates (or removes) the app's conf file without touching nginx.
# Prints one of: changed, unchanged, skipped.
fn-ga-write-conf() {
declare APP="$1"
local conf dir tmp
conf="$(fn-ga-conf-path "$APP")"
dir="$(dirname "$conf")"
if ! fn-google-auth-app-enabled "$APP"; then
if [[ -f "$conf" ]]; then
rm -f "$conf"
echo changed
else
echo unchanged
fi
return 0
fi
tmp="$(mktemp)"
if ! fn-ga-generate-conf "$APP" >"$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 "<no value>",
# and are stale by definition.
[[ -n "$labelled" && "$labelled" != "<no value>" ]] || 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" <<EOF
GOOGLE_AUTH_CLIENT_ID=$(fn-ga-global-get client-id)
GOOGLE_AUTH_CLIENT_SECRET=$(fn-ga-global-get client-secret)
GOOGLE_AUTH_COOKIE_SECRET=$(fn-ga-global-get cookie-secret)
GOOGLE_AUTH_AUTH_HOST=$(fn-ga-global-get auth-host)
GOOGLE_AUTH_ALLOWED_DOMAINS=$domains
GOOGLE_AUTH_ALLOWED_EMAILS=$emails
GOOGLE_AUTH_DENIED_EMAILS=$denied
GOOGLE_AUTH_APP_CONFIG_DIR=$GOOGLE_AUTH_APP_CONFIG_MOUNT
GOOGLE_AUTH_COOKIE_NAME=$(fn-ga-global-get cookie-name _google_auth)
GOOGLE_AUTH_SESSION_TTL=$(fn-ga-global-get session-ttl 24h)
GOOGLE_AUTH_ALLOW_INSECURE=$(fn-ga-global-get allow-insecure false)
GOOGLE_AUTH_LISTEN=:2999
EOF
)
}
fn-ga-service-start() {
fn-ga-configured || dokku_log_fail "google-auth is not fully configured; run: dokku google-auth:configure"
# Rebuild whenever the source moved, not merely when the image is missing.
# Starting the service is how an operator expects to pick up an upgraded
# plugin, and reusing the old binary there is what makes a new feature look
# broken rather than absent.
fn-ga-image-current || fn-ga-build-image
fn-ga-write-env-file
local port
port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")"
mkdir -p "$GOOGLE_AUTH_DATA_ROOT/apps"
chmod 711 "$GOOGLE_AUTH_DATA_ROOT/apps" 2>/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
}