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

. /lib/functions.sh
. /lib/functions/procd.sh

USE_PROCD=1

START=90
STOP=10

PROG=/usr/bin/gecoosac
DEFAULT_DB_DIR=/etc/gecoosac/
DEFAULT_UPLOAD_DIR=/tmp/gecoosac/upload/
DEFAULT_CRT_FILE=/etc/gecoosac/tls/gecoosac.crt
DEFAULT_KEY_FILE=/etc/gecoosac/tls/gecoosac.key
DEFAULT_PID_DIR=/var/run/
DEFAULT_LANG=zh
CERT_TIMEOUT=60

init_conf() {
	config_load "gecoosac"
	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_abs_path() {
	case "$1" in
		/*) return 0 ;;
		*) return 1 ;;
	esac
}

strip_trailing_slashes() {
	local path="$1"

	while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
		path="${path%/}"
	done

	printf '%s\n' "$path"
}

normalize_path() {
	local path="$1"
	local old_ifs part normalized parent

	is_abs_path "$path" || return 1
	normalized="/"
	old_ifs="$IFS"
	IFS=/
	set -- $path
	IFS="$old_ifs"

	for part in "$@"; do
		case "$part" in
			""|.) ;;
			..)
				if [ "$normalized" != "/" ]; then
					parent="${normalized%/*}"
					[ -n "$parent" ] || parent="/"
					normalized="$parent"
				fi
			;;
			*) normalized="${normalized%/}/$part" ;;
		esac
	done

	printf '%s\n' "$normalized"
}

is_safe_upload_dir() {
	local path

	path="$(normalize_path "$1")" || return 1
	case "$path" in
		/etc/gecoosac|/etc/gecoosac/*) return 1 ;;
	esac

	case "$path" in
		*/gecoosac/upload) return 0 ;;
	esac

	return 1
}

is_path_in_dir() {
	local path root

	path="$(normalize_path "$1")" || return 1
	root="$(normalize_path "$2")" || return 1

	[ "$root" != "/" ] || return 1
	[ "$path" = "$root" ] && return 0
	[ "${path#"$root"/}" != "$path" ]
}

is_safe_db_dir() {
	local path upload_root

	path="$(normalize_path "$1")" || return 1
	upload_root="$(normalize_path "${2:-$DEFAULT_UPLOAD_DIR}")" || return 1

	case "$path" in
		/etc/gecoosac|/etc/gecoosac/*|/tmp/gecoosac|/tmp/gecoosac/*|/var/lib/gecoosac|/var/lib/gecoosac/*) ;;
		*) return 1 ;;
	esac
	is_path_in_dir "$path" "$upload_root" && return 1

	return 0
}

is_safe_pid_dir() {
	local path upload_root

	path="$(normalize_path "$1")" || return 1
	upload_root="$(normalize_path "${2:-$DEFAULT_UPLOAD_DIR}")" || return 1

	case "$path" in
		/var/run|/var/run/*|/tmp/gecoosac|/tmp/gecoosac/*) ;;
		*) return 1 ;;
	esac
	is_path_in_dir "$path" "$upload_root" && return 1

	return 0
}

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

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

run_with_timeout() {
	local timeout pid i

	timeout="$1"
	shift

	"$@" >/dev/null 2>&1 &
	pid="$!"
	i=0

	while kill -0 "$pid" >/dev/null 2>&1; do
		if [ "$i" -ge "$timeout" ]; then
			kill "$pid" >/dev/null 2>&1
			sleep 1
			kill -9 "$pid" >/dev/null 2>&1
			wait "$pid" 2>/dev/null
			return 1
		fi
		sleep 1
		i=$((i + 1))
	done

	wait "$pid"
}

is_ipv4() {
	local old_ifs part

	old_ifs="$IFS"
	IFS=.
	set -- $1
	IFS="$old_ifs"

	[ "$#" -eq 4 ] || return 1
	for part in "$@"; do
		case "$part" in
			""|*[!0-9]*) return 1 ;;
		esac
		[ "$part" -le 255 ] 2>/dev/null || return 1
	done

	return 0
}

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

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

cert_matches_san() {
	local cert_file="$1"
	local cert_host="$2"
	local cert_ip="$3"
	local san

	[ -s "$cert_file" ] || return 1
	san="$(openssl x509 -in "$cert_file" -noout -ext subjectAltName 2>/dev/null)" || return 1
	san_has_entry "$san" "DNS:${cert_host}" || return 1
	[ -z "$cert_ip" ] || san_has_entry "$san" "IP Address:${cert_ip}" || return 1

	return 0
}

cert_matches_key() {
	local cert_file="$1"
	local key_file="$2"
	local cert_pub key_pub

	[ -s "$cert_file" ] && [ -s "$key_file" ] || return 1
	cert_pub="$(openssl x509 -in "$cert_file" -noout -pubkey 2>/dev/null)" || return 1
	key_pub="$(openssl pkey -in "$key_file" -pubout 2>/dev/null)" || return 1

	[ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ]
}

generate_default_cert() {
	local cert_host cert_ip cert_cn cert_san tmp_crt tmp_key

	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 [ -s "$DEFAULT_KEY_FILE" ] && cert_matches_san "$DEFAULT_CRT_FILE" "$cert_host" "$cert_ip" && cert_matches_key "$DEFAULT_CRT_FILE" "$DEFAULT_KEY_FILE"; then
		return 0
	fi

	mkdir -p /etc/gecoosac/tls

	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"

	rm -f "$tmp_crt" "$tmp_key"
	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"

	if [ -s "$tmp_crt" ] && [ -s "$tmp_key" ] && cert_matches_san "$tmp_crt" "$cert_host" "$cert_ip" && cert_matches_key "$tmp_crt" "$tmp_key"; then
		mv "$tmp_key" "$DEFAULT_KEY_FILE"
		mv "$tmp_crt" "$DEFAULT_CRT_FILE"
		chmod 600 "$DEFAULT_KEY_FILE"
		chmod 644 "$DEFAULT_CRT_FILE"
		return 0
	fi

	rm -f "$tmp_crt" "$tmp_key"
	logger -t gecoosac "failed to generate default HTTPS certificate"
	return 1
}

normalize_conf() {
	if is_safe_upload_dir "$upload_dir"; then
		upload_dir="$(normalize_path "$upload_dir")/"
	else
		upload_dir="$DEFAULT_UPLOAD_DIR"
	fi
	if is_safe_db_dir "$db_dir" "$upload_dir"; then
		db_dir="$(normalize_path "$db_dir")/"
	else
		db_dir="$DEFAULT_DB_DIR"
	fi
	is_abs_path "$crt_file" || crt_file="$DEFAULT_CRT_FILE"
	is_abs_path "$key_file" || key_file="$DEFAULT_KEY_FILE"
	if is_safe_pid_dir "$piddir" "$upload_dir"; then
		piddir="$(normalize_path "$piddir")/"
	else
		piddir="$DEFAULT_PID_DIR"
	fi
	is_port "$port" || port="60650"
	is_port "$m_port" || m_port="8080"

	[ "$enabled" = "1" ] || enabled="0"
	[ "$isonlyoneprot" = "0" ] || isonlyoneprot="1"
	[ "$https" = "1" ] || https="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 ! mkdir -p "$upload_dir" "$db_dir" "$piddir" /etc/gecoosac/tls; then
		logger -t gecoosac "failed to create runtime directories"
		return 1
	fi
}

start_service() {
	init_conf
	normalize_conf

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

	if [ "$isonlyoneprot" = "0" ] && [ "$port" = "$m_port" ]; 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
		fi

		if [ ! -r "$crt_file" ] || [ ! -r "$key_file" ]; then
			logger -t gecoosac "HTTPS is enabled but certificate or key file is missing"
			return 1
		fi

		if ! cert_matches_key "$crt_file" "$key_file"; then
			logger -t gecoosac "HTTPS certificate and key do not match"
			return 1
		fi
	fi

	procd_open_instance gecoosac
	procd_set_param command "$PROG"
	procd_append_param command -f "$upload_dir"
	procd_append_param command -p "$port"
	procd_append_param command -debug "$debug"
	procd_append_param command -dbpath "$db_dir"
	procd_append_param command -token "1"
	procd_append_param command -lang "$lang"
	procd_append_param command -piddir "$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
}

reload_service() {
	stop
	start
}

service_triggers() {
	procd_add_reload_trigger "gecoosac"
}
