#!/usr/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-FileCopyrightText: 2026 grommunio GmbH
#
# grommunio-auth adaptor for grommunio-meet (jitsi-meet): prosody token
# authentication against the Keycloak realm keys, jicofo secure domain and
# the jitsi-meet built-in SSO flow with the "grommunio-meet" client.
# Handles the openSUSE layout (/srv/jitsi-meet) and the Debian one
# (/etc/jitsi/meet/<host>-config.js).

set -e

PROGNAME="`basename $0`"
ADAPTOR_CONFIG_DIR='/var/cache/grommunio-auth/adaptor-config'
GMEET_ADAPTOR_CONFIG='keycloak-grommunio-meet.json'
GMEET_ADAPTOR_PUBKEY='keycloak-grommunio-meet.bearer_pubkey'
GMEET_ADAPTOR_CFG="${ADAPTOR_CONFIG_DIR}/${GMEET_ADAPTOR_CONFIG}"
KEYCLOAK_CONF='/etc/grommunio-keycloak/keycloak.conf'
SUSE_CONFIG_JS='/srv/jitsi-meet/config.js'
DEBIAN_MEET_DIR='/etc/jitsi/meet'
JICOFO_CONF='/etc/jitsi/jicofo/jicofo.conf'
JICOFO_AUTH_CONF='/etc/jitsi/jicofo/grommunio-auth.conf'
NGINX_SNIPPET_SRC='/usr/share/grommunio-auth/nginx/grommunio-auth-meet.conf'
NGINX_SNIPPET='/etc/grommunio-common/nginx/locations.d/grommunio-auth-meet.conf'
MARK='grommunio-auth'
NGINX_RELOAD=0


check_if_root () {
	local MY_EUID=$(id -u)
	if [ -n "$MY_EUID" -a $MY_EUID -ne 0 ]; then
		echo "${PROGNAME}: this program must be run as root" 1>&2
		exit 2
	fi
}

die () {
	echo "${PROGNAME}: $*" 1>&2
	exit 1
}

urlencode () {
	jq -rn --arg s "$1" '$s|@uri'
}

# Write a rebuilt file back through symlinks, keeping owner and mode
replace_file () {
	cat "$2" > "$1"
	rm -f "$2"
}

file_sum () {
	[ -e "$1" ] && sha256sum "$1" | cut -d ' ' -f 1 || echo none
}

# hosts.domain of a jitsi-meet config.js
config_js_domain () {
	sed -n "s/^[[:space:]]*domain:[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" "$1" | head -n 1
}

# jvb hostname on Debian: debconf, else the single /etc/jitsi/meet/*-config.js
debian_meet_host () {
	local host=''
	if [ -n "$DEBIAN_HAS_FRONTEND" ] && [ -r /usr/share/debconf/confmodule ]; then
		. /usr/share/debconf/confmodule
		db_get jitsi-videobridge/jvb-hostname && host=$(echo "$RET" | xargs echo -n)
	elif command -v debconf-show > /dev/null 2>&1; then
		host=$(debconf-show jitsi-videobridge 2> /dev/null | sed -n 's/^\*\{0,1\} *jitsi-videobridge\/jvb-hostname: *//p')
	fi
	if [ -z "$host" ]; then
		set -- "${DEBIAN_MEET_DIR}"/*-config.js
		[ $# -eq 1 ] && [ -f "$1" ] && host=$(basename "$1" -config.js)
	fi
	echo "$host"
}

# Sets LAYOUT, CONFIG_JS, DOMAIN, PROSODY_CFG, MEET_URL and SSO_REDIRECT;
# fails when no jitsi-meet installation is found.
detect_layout () {
	if [ -s "$SUSE_CONFIG_JS" ]; then
		LAYOUT='suse'
		CONFIG_JS="$SUSE_CONFIG_JS"
		DOMAIN=$(config_js_domain "$CONFIG_JS")
		[ -n "$DOMAIN" ] || die "no hosts.domain in ${CONFIG_JS}"
		PROSODY_CFG="/etc/prosody/conf.d/${DOMAIN}.cfg.lua"
		MEET_URL="https://${FQDN}/meet"
	elif [ -d "$DEBIAN_MEET_DIR" ]; then
		DOMAIN=$(debian_meet_host)
		[ -n "$DOMAIN" ] && [ -s "${DEBIAN_MEET_DIR}/${DOMAIN}-config.js" ] || return 1
		LAYOUT='debian'
		CONFIG_JS="${DEBIAN_MEET_DIR}/${DOMAIN}-config.js"
		PROSODY_CFG="/etc/prosody/conf.avail/${DOMAIN}.cfg.lua"
		[ -e "$PROSODY_CFG" ] || PROSODY_CFG="/etc/prosody/conf.d/${DOMAIN}.cfg.lua"
		MEET_URL="https://${DOMAIN}"
	else
		return 1
	fi
	SSO_REDIRECT="${MEET_URL}/static/sso.html"
}

# Keycloak as reached locally by prosody; port and path from keycloak.conf
keycloak_local_url () {
	local port='9080' path='/auth' v
	if [ -r "$KEYCLOAK_CONF" ]; then
		v=$(sed -n 's/^http-port=[[:space:]]*//p' "$KEYCLOAK_CONF" | tail -n 1)
		[ -n "$v" ] && port="$v"
		v=$(sed -n 's/^http-relative-path=[[:space:]]*//p' "$KEYCLOAK_CONF" | tail -n 1)
		[ -n "$v" ] && path="/${v#/}"
	fi
	echo "http://127.0.0.1:${port}${path%/}"
}

# Lua version prosody runs on, e.g. 5.4
prosody_lua_version () {
	local v=''
	if command -v prosodyctl > /dev/null 2>&1; then
		v=$(prosodyctl about 2> /dev/null | sed -n 's/^Lua version:[[:space:]]*Lua \([0-9]\.[0-9]\).*/\1/p' | head -n 1)
	fi
	if [ -z "$v" ] && [ -r /usr/bin/prosody ]; then
		v=$(head -n 1 /usr/bin/prosody | sed -n 's/^#!.*lua\([0-9]\.[0-9]\).*/\1/p')
	fi
	echo "$v"
}

# The token plugins need luaossl, basexx, cjson and inspect for prosody's
# Lua; complain (exit 3) instead of leaving prosody with a broken config.
check_lua_modules () {
	local ver lua pfx mod missing='' pkgs=''
	ver=$(prosody_lua_version)
	lua=$(command -v "lua${ver}" 2> /dev/null || command -v lua 2> /dev/null || true)
	if [ -z "$lua" ]; then
		echo "${PROGNAME}: no Lua interpreter found, skipping module check" 1>&2
		return 0
	fi
	[ -f /etc/debian_version ] && pfx='lua' || pfx="lua${ver/./}"
	for mod in openssl.pkey basexx cjson.safe inspect; do
		"$lua" -e "require '${mod}'" > /dev/null 2>&1 && continue
		missing="${missing} ${mod}"
		case "$mod" in
			openssl.pkey) pkgs="${pkgs} ${pfx}-luaossl" ;;
			cjson.safe) pkgs="${pkgs} ${pfx}-cjson" ;;
			*) pkgs="${pkgs} ${pfx}-${mod}" ;;
		esac
	done
	[ -n "$missing" ] || return 0
	echo "${PROGNAME}: prosody (Lua ${ver:-?}) lacks the modules:${missing}" 1>&2
	echo "${PROGNAME}: install${pkgs} and re-run the grommunio-auth client setup" 1>&2
	exit 3
}

# Value of "<option> = ..." inside the VirtualHost "<domain>" block
prosody_host_option () {
	awk -v domain="$2" -v opt="$3" '
	function hostline(s) { sub(/[ \t]*--.*$/, "", s); sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
	hostline($0) == "VirtualHost \"" domain "\"" { inhost = 1; next }
	inhost && /^[ \t]*(VirtualHost|Component)[ \t]/ { inhost = 0 }
	inhost && $0 ~ "^[ \t]*" opt "[ \t]*=" {
		v = hostline($0)
		sub(/^[^=]*=[ \t]*/, "", v)
		print v
		exit
	}
	' "$1"
}

# Modules enabled on the VirtualHost "<domain>" block, one per line
prosody_host_modules () {
	awk -v domain="$2" '
	function hostline(s) { sub(/[ \t]*--.*$/, "", s); sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
	hostline($0) == "VirtualHost \"" domain "\"" { inhost = 1; next }
	inhost && /^[ \t]*(VirtualHost|Component)[ \t]/ { inhost = 0 }
	inhost && /^[ \t]*modules_enabled[ \t]*=/ { inmods = 1 }
	inmods {
		line = $0
		sub(/--.*$/, "", line)
		while (match(line, /"[^"]*"/)) {
			print substr(line, RSTART + 1, RLENGTH - 2)
			line = substr(line, RSTART + RLENGTH)
		}
		if (line ~ /}/) inmods = 0
	}
	' "$1"
}

# Switch VirtualHost "$DOMAIN" to token authentication with a marked option
# block and add a guest host for anonymous participants. Re-runnable.
prosody_setup () {
	local cfg="$1" auth mods guest_mods='' m opt tmp
	auth=$(prosody_host_option "$cfg" "$DOMAIN" authentication)
	case "$auth" in
		'"anonymous"'|'"jitsi-anonymous"'|'"token"') ;;
		'') die "no VirtualHost \"${DOMAIN}\" with authentication in ${cfg}" ;;
		*) die "unsupported authentication ${auth} on VirtualHost \"${DOMAIN}\" in ${cfg}" ;;
	esac
	[ -e "${cfg}.${MARK}.orig" ] || cp -p "$cfg" "${cfg}.${MARK}.orig"

	# guests get the client facing modules of the main host
	mods=" $(prosody_host_modules "$cfg" "$DOMAIN" | tr '\n' ' ') "
	for m in bosh websocket ping external_services conference_duration speakerstats; do
		case "$m" in
			bosh|websocket|ping) ;;
			*) case "$mods" in *" ${m} "*) ;; *) continue ;; esac ;;
		esac
		guest_mods="${guest_mods} \"${m}\";"
	done
	GUEST_OPTS="authentication = \"anonymous\"
c2s_require_encryption = false
modules_enabled = {${guest_mods} }"
	for opt in speakerstats_component conference_duration_component; do
		case "$guest_mods" in *"\"${opt%_component}\""*) ;; *) continue ;; esac
		m=$(prosody_host_option "$cfg" "$DOMAIN" "$opt")
		if [ -n "$m" ]; then
			GUEST_OPTS="${GUEST_OPTS}
${opt} = ${m}"
		fi
	done
	HOST_OPTS="-- ${MARK} begin
app_id = \"${KC_ID}\"
cache_keys_url = \"${JWKS_URL}\"
asap_accepted_issuers = { \"${ISSUER}\" }
asap_accepted_audiences = { \"${KC_ID}\" }
asap_require_room_claim = false
enable_domain_verification = false
allow_empty_token = false
-- ${MARK} end"
	export HOST_OPTS GUEST_OPTS

	tmp=$(mktemp)
	awk -v domain="$DOMAIN" -v mark="$MARK" '
	function hostline(s) { sub(/[ \t]*--.*$/, "", s); sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
	function emit(text, ind,   n, i, l) { n = split(text, l, "\n"); for (i = 1; i <= n; i++) print ind l[i] }
	BEGIN {
		host = "VirtualHost \"" domain "\""
		guest = "VirtualHost \"guest." domain "\""
		indent = "    "
	}
	FNR == 1 { skip = 0; inhost = 0 }
	# drop blocks from earlier runs
	$0 ~ "^[ \t]*-- " mark " begin" { skip = 1 }
	skip { if ($0 ~ "^[ \t]*-- " mark " end") skip = 0; next }
	inhost && /^[ \t]*(VirtualHost|Component)[ \t]/ { inhost = 0 }
	NR == FNR {
		# first pass: indentation of the main host, existing guest host
		if (hostline($0) == host) inhost = 1
		else if (hostline($0) == guest) hasguest = 1
		else if (inhost && !indentset && match($0, /^[ \t]+[^ \t]/)) {
			indent = substr($0, 1, RLENGTH - 1)
			indentset = 1
		}
		next
	}
	inhost && /^[ \t]*authentication[ \t]*=[ \t]*"(jitsi-)?anonymous"/ { sub(/"(jitsi-)?anonymous"/, "\"token\"") }
	{ print; prev = $0 }
	hostline($0) == host { inhost = 1; emit(ENVIRON["HOST_OPTS"], indent) }
	END {
		if (hasguest) exit
		if (prev != "") print ""
		print "-- " mark " begin guest"
		print guest
		emit(ENVIRON["GUEST_OPTS"], indent)
		print "-- " mark " end guest"
	}
	' "$cfg" "$cfg" > "$tmp"
	replace_file "$cfg" "$tmp"
}

# Remove the marked blocks and put the original authentication back
prosody_delete () {
	local cfg="$1" auth tmp
	[ -e "$cfg" ] || return 0
	auth=$(prosody_host_option "${cfg}.${MARK}.orig" "$DOMAIN" authentication 2> /dev/null || true)
	auth=${auth#\"}
	auth=${auth%\"}
	case "$auth" in
		''|token) [ "$LAYOUT" = 'debian' ] && auth='jitsi-anonymous' || auth='anonymous' ;;
	esac
	tmp=$(mktemp)
	awk -v domain="$DOMAIN" -v mark="$MARK" -v auth="$auth" '
	function hostline(s) { sub(/[ \t]*--.*$/, "", s); sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
	# blank lines are held back so the one added before a block goes with it
	$0 ~ "^[ \t]*-- " mark " begin" { skip = 1; blank = 0 }
	skip { if ($0 ~ "^[ \t]*-- " mark " end") skip = 0; next }
	$0 == "" { blank++; next }
	{ for (; blank > 0; blank--) print "" }
	inhost && /^[ \t]*(VirtualHost|Component)[ \t]/ { inhost = 0 }
	inhost && /^[ \t]*authentication[ \t]*=[ \t]*"token"/ { sub(/"token"/, "\"" auth "\"") }
	{ print }
	hostline($0) == "VirtualHost \"" domain "\"" { inhost = 1 }
	END { for (; blank > 0; blank--) print "" }
	' "$cfg" > "$tmp"
	replace_file "$cfg" "$tmp"
}

# jicofo secure domain: token users are owners, guests wait for a host.
# With a client_proxy component jicofo sees the proxied JID and must be
# told to unwrap it, or every user fails the login-url domain check.
jicofo_setup () {
	local conf="$1" auth="$2" proxy=''
	[ -e "$conf" ] || die "${conf} not found"
	if grep -qE "^[[:space:]]*Component[[:space:]]+\"focus\.${DOMAIN}\"[[:space:]]+\"client_proxy\"" "$PROSODY_CFG"; then
		proxy="focus.${DOMAIN}"
	fi
	{
		echo "# written by ${PROGNAME}, included from $(basename "$conf")"
		echo "jicofo {"
		echo "  authentication {"
		echo "    enabled = true"
		echo "    type = JWT"
		echo "    login-url = \"${DOMAIN}\""
		echo "  }"
		if [ -n "$proxy" ]; then
			echo "  xmpp {"
			echo "    client {"
			echo "      client-proxy = \"${proxy}\""
			echo "    }"
			echo "  }"
		fi
		echo "}"
	} > "$auth"
	chown --reference="$conf" "$auth"
	chmod --reference="$conf" "$auth"
	grep -qF "include file(\"${auth}\")" "$conf" || printf '\ninclude file("%s")\n' "$auth" >> "$conf"
}

jicofo_delete () {
	local conf="$1" auth="$2" tmp
	rm -f "$auth"
	[ -e "$conf" ] || return 0
	tmp=$(mktemp)
	awk -v inc="include file(\"${auth}\")" '
	$0 == inc { blank = 0; next }
	$0 == "" { blank++; next }
	{ for (; blank > 0; blank--) print ""; print }
	END { for (; blank > 0; blank--) print "" }
	' "$conf" > "$tmp"
	replace_file "$conf" "$tmp"
}

# Marked block after the config object: guest domain and the SSO flow
config_js_setup () {
	local js="$1" tmp
	grep -q '^var config = {' "$js" || die "no config object in ${js}"
	CONFIG_BLOCK="// ${MARK} begin
config.hosts.anonymousdomain = 'guest.${DOMAIN}';
config.tokenAuthUrl = '${ISSUER}/protocol/openid-connect/auth?client_id=${KC_ID}&response_type=code&scope=openid%20profile%20email&redirect_uri=$(urlencode "$SSO_REDIRECT")&code_challenge_method=S256&code_challenge={code_challenge}&state={state}';
config.tokenAuthInline = true;
config.tokenGetUserInfoOutOfContext = true;
config.tokenLogoutUrl = '${ISSUER}/protocol/openid-connect/logout?client_id=${KC_ID}&post_logout_redirect_uri=$(urlencode "${MEET_URL}/")';
config.sso = { tokenService: '${TOKEN_SERVICE}', clientId: '${KC_ID}' };
// ${MARK} end"
	export CONFIG_BLOCK
	tmp=$(mktemp)
	awk -v mark="$MARK" '
	$0 ~ "^// " mark " begin" { skip = 1 }
	skip { if ($0 ~ "^// " mark " end") skip = 0; next }
	{ print }
	/^var config = [{]/ { inobj = 1 }
	inobj && /^};/ { inobj = 0; print ENVIRON["CONFIG_BLOCK"] }
	' "$js" > "$tmp"
	replace_file "$js" "$tmp"
}

config_js_delete () {
	local js="$1" tmp
	[ -e "$js" ] || return 0
	tmp=$(mktemp)
	awk -v mark="$MARK" '
	$0 ~ "^// " mark " begin" { skip = 1 }
	skip { if ($0 ~ "^// " mark " end") skip = 0; next }
	{ print }
	' "$js" > "$tmp"
	replace_file "$js" "$tmp"
}

# openSUSE: the SSO callback page needs SSI, served from the /meet subpath
nginx_setup () {
	[ "$LAYOUT" = 'suse' ] && [ -f "$NGINX_SNIPPET_SRC" ] || return 0
	[ -d "$(dirname "$NGINX_SNIPPET")" ] || return 0
	if ! cmp -s "$NGINX_SNIPPET_SRC" "$NGINX_SNIPPET"; then
		cp -v "$NGINX_SNIPPET_SRC" "$NGINX_SNIPPET"
		NGINX_RELOAD=1
	fi
}

nginx_delete () {
	if [ -e "$NGINX_SNIPPET" ]; then
		rm -vf "$NGINX_SNIPPET"
		NGINX_RELOAD=1
	fi
}

# Restart the first unit of the candidates that exists
restart_unit () {
	local u
	for u in "$@"; do
		if systemctl cat "$u" > /dev/null 2>&1; then
			echo -n "Restarting ${u}..."
			systemctl try-restart "$u"
			echo " done."
			return 0
		fi
	done
	echo "${PROGNAME}: no unit ${*} found, restart manually" 1>&2
}

# Restart only what changed since snapshot_config
snapshot_config () {
	PROSODY_SUM=$(file_sum "$PROSODY_CFG")
	JICOFO_SUM="$(file_sum "$JICOFO_CONF") $(file_sum "$JICOFO_AUTH_CONF")"
}

apply_services () {
	if [ "$(file_sum "$PROSODY_CFG")" != "$PROSODY_SUM" ]; then
		restart_unit prosody
	fi
	if [ "$(file_sum "$JICOFO_CONF") $(file_sum "$JICOFO_AUTH_CONF")" != "$JICOFO_SUM" ]; then
		restart_unit jitsi-jicofo jicofo
	fi
	if [ $NGINX_RELOAD -eq 1 ]; then
		echo -n "Reloading nginx..."
		if nginx -t > /dev/null 2>&1; then
			systemctl try-reload-or-restart nginx
			echo " done."
		else
			echo
			die "nginx -t failed, nginx not reloaded"
		fi
	fi
}

case "$PROGNAME" in
	setup-gk-app-g-meet)
		check_if_root
		[ -s "$GMEET_ADAPTOR_CFG" ] || die "${GMEET_ADAPTOR_CFG} not found"
		KC_ID=$(jq -r .resource "$GMEET_ADAPTOR_CFG")
		KC_REALM=$(jq -r .realm "$GMEET_ADAPTOR_CFG")
		KC_URL=$(jq -r '."auth-server-url"' "$GMEET_ADAPTOR_CFG")
		KC_URL="${KC_URL%/}"
		FQDN=$(echo "$KC_URL" | sed -e 's#^[a-z]*://##' -e 's#[/:].*##')
		ISSUER="${KC_URL}/realms/${KC_REALM}"
		TOKEN_SERVICE="${ISSUER#*://}/protocol/openid-connect"
		JWKS_URL="$(keycloak_local_url)/realms/${KC_REALM}/protocol/openid-connect/certs"
		detect_layout || die "jitsi-meet is not set up, nothing to do"
		check_lua_modules
		[ -s "$PROSODY_CFG" ] || die "prosody host config ${PROSODY_CFG} not found"

		snapshot_config
		echo "Configuring jitsi-meet on ${DOMAIN} (${LAYOUT} layout) for ${ISSUER}"
		prosody_setup "$PROSODY_CFG"
		jicofo_setup "$JICOFO_CONF" "$JICOFO_AUTH_CONF"
		config_js_setup "$CONFIG_JS"
		nginx_setup
		apply_services
		# Clean up stragglers
		shred "${ADAPTOR_CONFIG_DIR}/${GMEET_ADAPTOR_CONFIG}" || true
		rm -vf "${ADAPTOR_CONFIG_DIR}/${GMEET_ADAPTOR_CONFIG}" || true
		rm -vf "${ADAPTOR_CONFIG_DIR}/${GMEET_ADAPTOR_PUBKEY}" || true
		;;
	delete-gk-app-g-meet)
		check_if_root
		if ! detect_layout; then
			echo "${PROGNAME}: jitsi-meet is not set up, nothing to do"
			exit 0
		fi
		snapshot_config
		echo "Removing the grommunio-auth configuration from jitsi-meet on ${DOMAIN}"
		prosody_delete "$PROSODY_CFG"
		jicofo_delete "$JICOFO_CONF" "$JICOFO_AUTH_CONF"
		config_js_delete "$CONFIG_JS"
		nginx_delete
		apply_services
		;;
esac
