#!/bin/sh /etc/rc.common
# Copyright (C) 2024 ImmortalWrt

. /lib/functions.sh
. /lib/functions/procd.sh
. /usr/share/gecoosac/common.sh

USE_PROCD=1

START=90
STOP=10

PROG=/usr/bin/gecoosac
DEFAULT_LANG=zh
CERT_TIMEOUT=60
CERT_RENEW_BEFORE=2592000

init_conf() {
	local section_type

	unset CONFIG_config_TYPE
	config_load "gecoosac" || {
		logger -t gecoosac "unable to read Gecoos configuration"
		return 1
	}
	config_get section_type config TYPE
	[ "$section_type" = "gecoosac" ] || {
		logger -t gecoosac "missing Gecoos configuration section"
		return 1
	}
	config_get "db_dir" "config" "db_dir" "$DEFAULT_DB_DIR"
	config_get "upload_dir" "config" "upload_dir" "$DEFAULT_UPLOAD_DIR"
	config_get "enabled" "config" "enabled" "0"
	config_get "port" "config" "port" "60650"
	config_get "isonlyoneprot" "config" "isonlyoneprot" "1"
	config_get "m_port" "config" "m_port" "8080"
	config_get "https" "config" "https" "0"
	config_get "crt_file" "config" "crt_file" "$DEFAULT_CRT_FILE"
	config_get "key_file" "config" "key_file" "$DEFAULT_KEY_FILE"
	config_get "piddir" "config" "piddir" "$DEFAULT_PID_DIR"
	config_get "lang" "config" "lang" "$DEFAULT_LANG"
	config_get "debug" "config" "debug" "0"
	config_get "showtip" "config" "showtip" "0"
	config_get "log" "config" "log" "0"
}

is_port() {
	case "$1" in
		""|*[!0-9]*) return 1 ;;
	esac

	[ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ]
}

is_ipv4() {
	local value="$1"
	local part count

	count=0

	while :; do
		part="${value%%.*}"
		count=$((count + 1))

		case "$part" in
			""|*[!0-9]*) return 1 ;;
		esac
		[ "$part" -le 255 ] 2>/dev/null || return 1

		[ "$part" != "$value" ] || break
		value="${value#*.}"
	done

	[ "$count" -eq 4 ]
}

san_has_entry() {
	local san="$1"
	local entry="$2"

	printf '%s\n' "$san" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -F -x -q "$entry"
}

run_with_timeout_output() {
	local limit="$1" output="$2"
	shift 2

	# coreutils-timeout waits for completion directly and also bounds commands
	# which ignore SIGTERM; no polling delay or orphaned sleep process is needed.
	timeout -k 1 "$limit" "$@" >"$output" 2>/dev/null
}

run_with_timeout() {
	local limit="$1"
	shift
	run_with_timeout_output "$limit" /dev/null "$@"
}

cert_pair_valid() {
	local cert_file="$1" key_file="$2" cert_host="$3" cert_ip="$4"
	local min_lifetime="${5:-0}" tmp_dir cert_pub key_pub san status

	tmp_dir="$(mktemp -d /tmp/gecoosac-cert.XXXXXX)" || return 1
	status=1
	if run_with_timeout "$CERT_TIMEOUT" /bin/sh -c '
		[ -f "$1" ] && [ -r "$1" ] && [ -s "$1" ] &&
		[ -f "$2" ] && [ -r "$2" ] && [ -s "$2" ]
	' gecoosac-file-check "$cert_file" "$key_file" </dev/null && \
		run_with_timeout_output "$CERT_TIMEOUT" "$tmp_dir/cert" \
			openssl x509 -in "$cert_file" -noout -pubkey -ext subjectAltName -checkend "$min_lifetime" </dev/null && \
		run_with_timeout_output "$CERT_TIMEOUT" "$tmp_dir/key.pub" \
			openssl pkey -in "$key_file" -pubout </dev/null && \
		run_with_timeout "$CERT_TIMEOUT" \
			openssl verify -partial_chain -trusted "$cert_file" "$cert_file" </dev/null; then
		# Trust the configured leaf only for the local validity-period check;
		# browser trust still depends on the certificate installed by the user.
		cert_pub="$(sed -n '/^-----BEGIN PUBLIC KEY-----$/,/^-----END PUBLIC KEY-----$/p' "$tmp_dir/cert")"
		key_pub="$(cat "$tmp_dir/key.pub")"
		if [ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ]; then
			status=0
			if [ -n "$cert_host" ]; then
				san="$(cat "$tmp_dir/cert")"
				san_has_entry "$san" "DNS:${cert_host}" || status=1
				[ -z "$cert_ip" ] || san_has_entry "$san" "IP Address:${cert_ip}" || status=1
			fi
		fi
	fi
	rm -rf "$tmp_dir"
	return "$status"
}

generate_default_cert() {
	local cert_host cert_ip cert_cn cert_san tmp_crt tmp_key
	local backup_dir old_crt old_key restore_ok path

	if ! command -v openssl >/dev/null 2>&1; then
		logger -t gecoosac "openssl is required to generate the default HTTPS certificate"
		return 1
	fi

	cert_host="$(uci -q get system.@system[0].hostname)"
	case "$cert_host" in
		""|*[!A-Za-z0-9.-]*) cert_host="GecoosAC" ;;
	esac

	cert_ip="$(uci -q get network.lan.ipaddr)"
	is_ipv4 "$cert_ip" || cert_ip=""

	if cert_pair_valid "$DEFAULT_CRT_FILE" "$DEFAULT_KEY_FILE" "$cert_host" "$cert_ip" "$CERT_RENEW_BEFORE"; then
		return 0
	fi

	cert_cn="${cert_ip:-$cert_host}"
	cert_san="DNS:$cert_host"
	[ -n "$cert_ip" ] && cert_san="${cert_san},IP:${cert_ip}"
	tmp_crt="${DEFAULT_CRT_FILE}.tmp"
	tmp_key="${DEFAULT_KEY_FILE}.tmp"

	if ! rm -f "$tmp_crt" "$tmp_key"; then
		logger -t gecoosac "unable to prepare default HTTPS certificate files"
		return 1
	fi
	if ! run_with_timeout "$CERT_TIMEOUT" openssl req \
		-x509 \
		-nodes \
		-newkey ec \
		-pkeyopt ec_paramgen_curve:prime256v1 \
		-pkeyopt ec_param_enc:named_curve \
		-sha256 \
		-days 3650 \
		-keyout "$tmp_key" \
		-out "$tmp_crt" \
		-subj "/CN=$cert_cn" \
		-addext "basicConstraints=critical,CA:FALSE" \
		-addext "keyUsage=digitalSignature" \
		-addext "extendedKeyUsage=serverAuth" \
		-addext "subjectAltName=$cert_san" </dev/null; then
		rm -f "$tmp_crt" "$tmp_key"
		logger -t gecoosac "failed to generate default HTTPS certificate"
		return 1
	fi

	if ! cert_pair_valid "$tmp_crt" "$tmp_key" "$cert_host" "$cert_ip" "$CERT_RENEW_BEFORE" || \
		! chmod 600 "$tmp_key" || ! chmod 644 "$tmp_crt"; then
		rm -f "$tmp_crt" "$tmp_key"
		logger -t gecoosac "failed to validate default HTTPS certificate"
		return 1
	fi

	for path in "$DEFAULT_CRT_FILE" "$DEFAULT_KEY_FILE"; do
		if [ -e "$path" ] || [ -L "$path" ]; then
			[ -f "$path" ] && [ ! -L "$path" ] || {
				rm -f "$tmp_crt" "$tmp_key"
				logger -t gecoosac "refusing unsafe default HTTPS certificate path: $path"
				return 1
			}
		fi
	done

	backup_dir="$(mktemp -d /tmp/gecoosac-cert-backup.XXXXXX)" || {
		rm -f "$tmp_crt" "$tmp_key"
		logger -t gecoosac "unable to back up default HTTPS certificate"
		return 1
	}
	old_crt=0
	old_key=0
	if [ -f "$DEFAULT_CRT_FILE" ]; then
		cp -p "$DEFAULT_CRT_FILE" "$backup_dir/cert" || {
			rm -f "$tmp_crt" "$tmp_key"
			rm -rf "$backup_dir"
			logger -t gecoosac "unable to back up default HTTPS certificate"
			return 1
		}
		old_crt=1
	fi
	if [ -f "$DEFAULT_KEY_FILE" ]; then
		cp -p "$DEFAULT_KEY_FILE" "$backup_dir/key" || {
			rm -f "$tmp_crt" "$tmp_key"
			rm -rf "$backup_dir"
			logger -t gecoosac "unable to back up default HTTPS private key"
			return 1
		}
		old_key=1
	fi

	# Both temporary files were validated above; rename preserves their contents.
	if mv "$tmp_key" "$DEFAULT_KEY_FILE" && mv "$tmp_crt" "$DEFAULT_CRT_FILE"; then
		rm -rf "$backup_dir"
		return 0
	fi

	rm -f "$tmp_crt" "$tmp_key"
	restore_ok=1
	rm -f "$DEFAULT_CRT_FILE" "$DEFAULT_KEY_FILE" || restore_ok=0
	if [ "$old_crt" = "1" ]; then
		cp -p "$backup_dir/cert" "$DEFAULT_CRT_FILE" || restore_ok=0
	fi
	if [ "$old_key" = "1" ]; then
		cp -p "$backup_dir/key" "$DEFAULT_KEY_FILE" || restore_ok=0
	fi
	if [ "$restore_ok" = "1" ]; then
		rm -rf "$backup_dir"
	else
		logger -t gecoosac "unable to restore default HTTPS certificate; backup retained at $backup_dir"
	fi
	logger -t gecoosac "failed to generate default HTTPS certificate"
	return 1
}

normalize_conf() {
	[ "$isonlyoneprot" = "0" ] || isonlyoneprot="1"
	[ "$https" = "1" ] || https="0"
	if is_safe_upload_dir "$upload_dir"; then
		upload_dir="$(normalize_path "$upload_dir")"
	else
		logger -t gecoosac "refusing unsupported upload directory: $upload_dir"
		return 1
	fi
	if path_uses_clear_stage "$upload_dir"; then
		logger -t gecoosac "refusing reserved upload cleanup stage path: $upload_dir"
		return 1
	fi
	if is_safe_db_dir "$db_dir" "$upload_dir"; then
		db_dir="$(normalize_path "$db_dir")"
	else
		logger -t gecoosac "refusing unsupported database directory: $db_dir"
		return 1
	fi
	if path_uses_clear_stage "$db_dir"; then
		logger -t gecoosac "refusing reserved upload cleanup stage path: $db_dir"
		return 1
	fi
	if [ "$isonlyoneprot" = "0" ] && [ "$https" = "1" ]; then
		is_abs_path "$crt_file" || {
			logger -t gecoosac "refusing non-absolute certificate path: $crt_file"
			return 1
		}
		is_abs_path "$key_file" || {
			logger -t gecoosac "refusing non-absolute key path: $key_file"
			return 1
		}
		if path_uses_clear_stage "$crt_file" || path_uses_clear_stage "$key_file"; then
			logger -t gecoosac "refusing reserved upload cleanup stage certificate path"
			return 1
		fi
	fi
	if is_safe_pid_dir "$piddir" "$upload_dir"; then
		piddir="$(normalize_path "$piddir")"
	else
		logger -t gecoosac "refusing unsupported PID directory: $piddir"
		return 1
	fi
	if path_uses_clear_stage "$piddir"; then
		logger -t gecoosac "refusing reserved upload cleanup stage path: $piddir"
		return 1
	fi
	is_port "$port" || {
		logger -t gecoosac "refusing invalid interface port: $port"
		return 1
	}
	if [ "$isonlyoneprot" = "0" ]; then
		is_port "$m_port" || {
			logger -t gecoosac "refusing invalid management port: $m_port"
			return 1
		}
	fi

	[ "$enabled" = "1" ] || enabled="0"
	case "$lang" in
		zh|en) ;;
		*) lang="$DEFAULT_LANG" ;;
	esac
	[ "$debug" = "1" ] || debug="0"
	[ "$showtip" = "1" ] || showtip="0"
	[ "$log" = "1" ] || log="0"
}

ensure_dirs() {
	if ! ensure_managed_dir upload "$upload_dir" || \
		! ensure_managed_dir db "$db_dir" || \
		! ensure_managed_dir pid "$piddir" || \
		! ensure_managed_dir tls /etc/gecoosac/tls; then
		logger -t gecoosac "failed to create runtime directories"
		return 1
	fi
}

ensure_secure_dir_tree() {
	local path current part rest mode

	path="$(normalize_path "$1")" || return 1
	current="/"
	rest="${path#/}"
	while [ -n "$rest" ]; do
		part="${rest%%/*}"
		if [ "$part" = "$rest" ]; then
			rest=""
		else
			rest="${rest#*/}"
		fi
		current="${current%/}/$part"
		[ ! -L "$current" ] || return 1
		if [ -e "$current" ]; then
			[ -d "$current" ] || return 1
		else
			mode=0755
			[ -n "$rest" ] || mode=0750
			mkdir -m "$mode" "$current" || return 1
		fi
		case "$current" in
			/tmp) is_secure_dir "$current" 1 || return 1 ;;
			*) is_secure_dir "$current" || return 1 ;;
		esac
	done
}

ensure_upload_storage_root() {
	local path="$1" root

	is_supported_upload_path "$path" || return 1
	case "$path" in
		"$DEFAULT_UPLOAD_DIR") root=/tmp ;;
		/mnt/*/gecoosac/upload) root="${path%/gecoosac/upload}" ;;
		*) return 1 ;;
	esac

	[ -d "$root" ] && [ ! -L "$root" ] || return 1
	is_secure_upload_dir "$root"
}

ensure_managed_dir() {
	local role="$1" path physical

	path="$(normalize_path "$2")" || return 1
	physical="$(managed_dir_path "$role" "$path")" || return 1
	case "$role" in
		upload)
		is_safe_upload_dir "$path" || return 1
		ensure_upload_storage_root "$physical" || return 1
	;;
		db) is_safe_db_dir "$path" "$upload_dir" || return 1 ;;
		pid) is_safe_pid_dir "$path" "$upload_dir" || return 1 ;;
		tls) [ "$path" = "/etc/gecoosac/tls" ] || return 1 ;;
		*) return 1 ;;
	esac
	ensure_secure_dir_tree "$physical" || return 1
	[ -d "$path" ]
}

prepare_service() {
	init_conf || return 1
	[ "$enabled" = "1" ] || return 0
	normalize_conf || return 1
	if [ ! -x "$PROG" ]; then
		logger -t gecoosac "program not found: $PROG"
		return 1
	fi

	if [ "$isonlyoneprot" = "0" ] && [ "$port" -eq "$m_port" ] 2>/dev/null; then
		logger -t gecoosac "interface port and management port must be different"
		return 1
	fi

	ensure_dirs || return 1
	if [ "$isonlyoneprot" = "0" ] && [ "$https" = "1" ]; then
		if [ "$crt_file" = "$DEFAULT_CRT_FILE" ] && [ "$key_file" = "$DEFAULT_KEY_FILE" ]; then
			generate_default_cert || return 1
		elif ! cert_pair_valid "$crt_file" "$key_file"; then
			logger -t gecoosac "HTTPS certificate is not currently valid, or its private key cannot be read or does not match"
			return 1
		fi
	fi
}

start_prepared_service() {
	local runtime_upload_dir runtime_db_dir runtime_piddir

	[ "$enabled" = "1" ] || return 0
	runtime_upload_dir="${upload_dir%/}/"
	runtime_db_dir="${db_dir%/}/"
	runtime_piddir="${piddir%/}/"

	procd_open_instance gecoosac
	procd_set_param command "$PROG"
	procd_append_param command -f "$runtime_upload_dir"
	procd_append_param command -p "$port"
	procd_append_param command -debug "$debug"
	procd_append_param command -dbpath "$runtime_db_dir"
	procd_append_param command -token "1"
	procd_append_param command -lang "$lang"
	procd_append_param command -piddir "$runtime_piddir"
	if [ "$isonlyoneprot" = "0" ]; then
		procd_append_param command -isonlyoneprot "0"
		procd_append_param command -mp "$m_port"
		if [ "$https" = "1" ]; then
			procd_append_param command -https "1"
			procd_append_param command -crt "$crt_file"
			procd_append_param command -key "$key_file"
		else
			procd_append_param command -https "0"
		fi
	else
		procd_append_param command -isonlyoneprot "1"
	fi
	procd_append_param command -showtip "$showtip"
	procd_set_param env GIN_MODE=release
	procd_set_param file /etc/config/gecoosac
	procd_set_param pidfile "${piddir%/}/gecoosac.pid"
	procd_set_param stdout "$log"
	procd_set_param stderr "$log"
	procd_set_param respawn "${respawn_threshold:-3600}" "${respawn_timeout:-5}" "${respawn_retry:-5}"
	procd_close_instance
}

start_service() {
	service_prepare_failed=0
	if ! prepare_service; then
		service_prepare_failed=1
		return 0
	fi
	start_prepared_service || service_prepare_failed=1
}

service_started() {
	[ "${service_prepare_failed:-0}" = "0" ]
}

service_running_state() {
	local data instances instance state

	data="$(ubus call service list '{"name":"gecoosac"}' 2>/dev/null)" || return 2
	json_load "$data" 2>/dev/null || return 2
	if ! json_select gecoosac 2>/dev/null; then
		json_cleanup
		return 0
	fi
	if ! json_select instances 2>/dev/null; then
		json_cleanup
		return 0
	fi
	json_get_keys instances
	for instance in $instances; do
		if ! json_select "$instance" 2>/dev/null; then
			json_cleanup
			return 2
		fi
		json_get_var state running
		json_select ..
		case "$state" in
			1|true)
				json_cleanup
				return 1
			;;
			0|false) ;;
			*)
				json_cleanup
				return 2
			;;
		esac
	done
	json_cleanup
	return 0
}

wait_service_stopped() {
	local i state

	i=0
	while :; do
		service_running_state
		state="$?"
		case "$state" in
			0) return 0 ;;
			1|2)
				[ "$i" -ge 5 ] && return 1
				sleep 1
				i=$((i + 1))
			;;
			*) return 1 ;;
		esac
	done
}

reload_service() {
	prepare_service || return 1
	if ! stop || ! wait_service_stopped; then
		logger -t gecoosac "unable to stop the existing service for reload"
		return 1
	fi
	rc_procd start_prepared_service
}

service_triggers() {
	procd_add_reload_trigger "gecoosac"
}
