#!/bin/sh
#
# tcblock_sync - TrapCloud 精准封堵:按分类条件拉去重 IP 灌入 Panabit 群组
#
# 用法:
#   tcblock_sync sync        # 按 tcblock.conf 当前条件同步一次
#
# 机制:
#   - pacu -k 拉 https://www.trapcloud.cn/api/export?key=..&type=..&service=..&level=..&precision=..&hours=..&format=txt
#   - 校验返回为 IP 列表;MD5 比对,无变化跳过 loadfile(省网关算力)
#   - floweye table add name=<群组>(不存在则建)+ table loadfile tid= clear=1 全量换库(只清不删群组)
#   - 单实例锁、磁盘空间检查、滚动日志
#

. /etc/PG.conf

# pacu / curl 探测(与 ajax_common 一致)
if [ -f "/usr/pabin/pacu" ]; then
	CURL="/usr/pabin/pacu"
elif [ -f "/usr/bin/curl" ]; then
	CURL="/usr/bin/curl"
else
	CURL=$(which curl 2>/dev/null)
fi

APPNAME=tcblock
CONFDIR="${PGETC}/App/${APPNAME}"
APPCONF="${CONFDIR}/tcblock.conf"
RAMROOT="/usr/ramdisk/app/${APPNAME}"
# sync.state 必须放持久化目录:里面的 grpname 是「防误覆盖」的唯一依据,
# 一旦随 ramdisk 丢失,重启后会把自家群组误判成他人群组而拒绝写入(2026-08-07 拦下过)。
SYNCFILE="${CONFDIR}/sync.state"
LOGFILE="${CONFDIR}/sync.log"
TMPDIR="/usr/ramdisk/app/${APPNAME}/tmp"
TMPFILE="${TMPDIR}/export.txt"
LOCKDIR="/tmp/tcblock.lock.d"
LOCKFILE_OLD="/tmp/tcblock.lock"   # 旧版的文件锁,升级后清理掉
FLOWEYE="/usr/ramdisk/bin/floweye"
BASE_URL="https://dl.trapcloud.cn:64005"   # 2026-07-25 起走 IDC 直连下载节点(三线BGP,不占主站带宽)
CURL_TIMEOUT=60
MAX_LOG=50

# ============================================================
# 🔴 配置读取:绝不 source 配置文件
# ============================================================
# `. "${APPCONF}"` 会把 conf 里的值送进 shell 解析器,以 root 执行 ——
# WebUI 写入时虽有白名单,但历史脏配置、手工编辑、升级异常都能绕过它。
# (szsync 1200 之前就是栽在同一类注入面上。)
# 一律按键读取 + 逐项白名单,任何非法值一律回退默认值。
conf_get() { [ -f "${APPCONF}" ] && grep "^$1=" "${APPCONF}" 2>/dev/null | tail -1 | cut -d'=' -f2-; }

# in_set <value> "<允许值列表>" <默认值>
in_set() { for _x in $2; do [ "$1" = "${_x}" ] && { echo "$1"; return; }; done; echo "$3"; }

# num_in <value> <min> <max> <默认值>:只保留数字,超界夹取,空则用默认
num_in()
{
	_v=$(echo "$1" | tr -cd '0-9' | cut -c1-9)
	[ -z "${_v}" ] && { echo "$4"; return; }
	[ "${_v}" -lt "$2" ] && _v="$2"
	[ "${_v}" -gt "$3" ] && _v="$3"
	echo "${_v}"
}

load_conf()
{
	enable=$(in_set "$(conf_get enable)" "0 1" "0")
	apikey=$(conf_get apikey | tr -cd 'A-Za-z0-9_-' | cut -c1-64)
	ktype=$(in_set "$(conf_get ktype)" "all attacker scanner research_scanner asset_scanner" "all")
	service=$(in_set "$(conf_get service)" "all ssh rdp vnc db web smb telnet mail ics ai proxy" "all")
	level=$(in_set "$(conf_get level)" "all critical high medium" "all")
	precision=$(in_set "$(conf_get precision)" "standard high" "standard")
	days=$(num_in "$(conf_get days)" 1 7 3)                 # 上限 7 天,与订阅窗口对齐
	interval=$(num_in "$(conf_get interval)" 5 525600 1440) # 最小 5 分钟
	grpname=$(conf_get grpname | tr -cd 'A-Za-z0-9_-' | cut -c1-64)
	[ -z "${grpname}" ] && grpname="TrapCloud_block"
}
load_conf

# ============================================================
# 工具函数
# ============================================================
calc_md5()
{
	if command -v md5sum >/dev/null 2>&1; then
		md5sum "$1" | cut -d' ' -f1
	else
		md5 -q "$1"
	fi
}

write_log()
{
	# write_log STATUS msg
	mkdir -p "${CONFDIR}"
	echo "$(date '+%Y-%m-%d %H:%M:%S')|${1}|${2}" >> "${LOGFILE}"
	if [ -f "${LOGFILE}" ]; then
		tail -${MAX_LOG} "${LOGFILE}" > "${LOGFILE}.tmp" 2>/dev/null && mv "${LOGFILE}.tmp" "${LOGFILE}"
	fi
}

# 🔴 原子锁:必须用 mkdir,不能「先判断存在再创建」
# 旧实现是 [ -f LOCK ] 检查 + echo > LOCK 写入,两步之间有窗口 ——
# 手动同步与 daemon 恰好同时进入时会双双拿到锁,导致并发下载(多耗配额)
# 和并发 floweye loadfile(同一群组被两个进程同时重灌)。
# mkdir 在同一文件系统上是原子的:要么建成(拿到锁),要么失败(别人持有)。
acquire_lock()
{
	# 🔴 旧版(≤100008)用的是文件锁 /tmp/tcblock.lock,必须按"有效锁"对待
	# 升级瞬间可能还有旧版 tcblock_sync 在跑并持有它。无条件 rm 等于抢别人的锁,
	# 会造成并发下载(多耗订阅配额)+ 并发 floweye loadfile(同一群组被两个进程重灌)。
	# 规则与新锁一致:未超 30 分钟视为有效 → 直接退让;超时才当僵死锁回收。
	if [ -f "${LOCKFILE_OLD}" ]; then
		olt=$(cat "${LOCKFILE_OLD}" 2>/dev/null | tr -cd '0-9')
		onow=$(date +%s)
		if [ -n "${olt}" ] && [ $((onow - olt)) -le 1800 ]; then
			return 1
		fi
		rm -f "${LOCKFILE_OLD}" 2>/dev/null
	fi

	if mkdir "${LOCKDIR}" 2>/dev/null; then
		_lock_taken
		return 0
	fi

	# 没拿到:看是不是超过 30 分钟的僵死锁
	lt=$(cat "${LOCKDIR}/ts" 2>/dev/null | tr -cd '0-9')
	now=$(date +%s)
	if [ -n "${lt}" ] && [ $((now - lt)) -gt 1800 ]; then
		rm -rf "${LOCKDIR}" 2>/dev/null
		if mkdir "${LOCKDIR}" 2>/dev/null; then
			_lock_taken
			return 0
		fi
	fi
	return 1
}

_lock_taken()
{
	date +%s > "${LOCKDIR}/ts" 2>/dev/null
	echo $$ > "${LOCKDIR}/pid" 2>/dev/null
	# 拿到锁之后才设 trap。INT/TERM 必须真的退出 ——
	# 只释放锁却继续跑,等于自己没锁还在下载/灌表,与别的进程并发。
	trap 'release_lock' EXIT
	trap 'release_lock; exit 130' INT
	trap 'release_lock; exit 143' TERM
}

# 🔴 只释放"自己持有"的锁
# 正常路径会显式 release_lock,随后脚本退出又触发 EXIT trap 再释放一次。
# 若这中间另一个进程已经 mkdir 拿到新锁,第二次释放就会把【别人的】锁删掉,
# 于是第三个进程也能进来 —— 原子锁被 trap 自己破坏。
# 因此释放前必须校验锁里的 pid 是不是自己。
release_lock()
{
	[ -d "${LOCKDIR}" ] || return 0
	_p=$(cat "${LOCKDIR}/pid" 2>/dev/null | tr -cd '0-9')
	[ "${_p}" = "$$" ] && rm -rf "${LOCKDIR}" 2>/dev/null
	return 0
}

check_disk_space()
{
	usage=$(df "${PGETC}" 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%')
	[ "${usage}" = "" ] && return 0
	[ ${usage} -ge 90 ] && return 1
	return 0
}

# name -> tid(精确匹配 floweye table list 的 "ID NAME")
get_table_id()
{
	${FLOWEYE} table list 2>/dev/null | awk -v n="$1" '{id=$1; $1=""; sub(/^ /,""); if($0==n){print id; exit}}'
}

# ============================================================
# 核心同步
# ============================================================
do_sync()
{
	start_time=$(date +%s)

	# 注:不在此判 enable —— 自动同步由 cron 把关(仅 enable=1 时存在 cron),
	# 手动"立即同步"是用户主动行为,应始终执行。
	if [ "${apikey}" = "" ] || [ "${grpname}" = "" ]; then
		write_log "FAIL" "missing_apikey_or_group"
		return 1
	fi

	acquire_lock || { write_log "SKIP" "locked"; return 1; }
	check_disk_space || { write_log "FAIL" "disk_full"; release_lock; return 1; }

	mkdir -p "${TMPDIR}" "${RAMROOT}/state"
	# 上限 7 天(2026-07-25:与订阅窗口对齐;旧配置残留的大值在此封顶)
	echo "${days}" | grep -qE '^[0-9]+$' || days=3
	[ ${days} -lt 1 ] && days=1
	[ ${days} -gt 7 ] && days=7
	hours=$((days * 24))
	url="${BASE_URL}/api/export?key=${apikey}&type=${ktype}&service=${service}&level=${level}&precision=${precision}&hours=${hours}&format=txt"

	http_code=$(${CURL} -k -s -m ${CURL_TIMEOUT} -o "${TMPFILE}" -w "%{http_code}" "${url}" 2>/dev/null)
	cret=$?
	if [ ${cret} -ne 0 ]; then
		write_log "FAIL" "curl_err:ret=${cret}"
		rm -f "${TMPFILE}"; release_lock; return 1
	fi
	if [ "${http_code}" != "200" ]; then
		body=$(head -1 "${TMPFILE}" 2>/dev/null | tr '"' "'" | cut -c1-100)
		write_log "FAIL" "http_${http_code}:${body}"
		rm -f "${TMPFILE}"; release_lock; return 1
	fi

	# 校验:至少一行 IP
	ip_count=$(grep -cE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "${TMPFILE}" 2>/dev/null)
	if [ "${ip_count}" = "" ] || [ "${ip_count}" = "0" ]; then
		fsz=$(wc -c < "${TMPFILE}" 2>/dev/null | tr -d ' ')
		h1=$(head -1 "${TMPFILE}" 2>/dev/null | tr '"' "'" | cut -c1-100)
		write_log "FAIL" "bad_format:size=${fsz},head=${h1}"
		rm -f "${TMPFILE}"; release_lock; return 1
	fi

	# 只保留纯 IP 行(去掉可能的注释/空行)
	grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "${TMPFILE}" > "${TMPFILE}.clean" && mv "${TMPFILE}.clean" "${TMPFILE}"

	# MD5 比对:无变化只更新 last_check
	new_md5=$(calc_md5 "${TMPFILE}")
	old_md5=""; prev_grp=""
	if [ -f "${SYNCFILE}" ]; then
		old_md5=$(grep '^md5=' "${SYNCFILE}" 2>/dev/null | cut -d'=' -f2)
		prev_grp=$(grep '^grpname=' "${SYNCFILE}" 2>/dev/null | cut -d'=' -f2)
	fi
	now=$(date +%s)
	# 内容未变 且 目标群组未变 才跳过(只改群组名也要强制重灌到新群组)
	if [ "${new_md5}" = "${old_md5}" ] && [ "${old_md5}" != "" ] && [ "${grpname}" = "${prev_grp}" ]; then
		if [ -f "${SYNCFILE}" ]; then
			grep -v '^last_check=' "${SYNCFILE}" > "${SYNCFILE}.tmp"
			echo "last_check=${now}" >> "${SYNCFILE}.tmp"
			mv "${SYNCFILE}.tmp" "${SYNCFILE}"
		fi
		write_log "SKIP" "no_change,${ip_count}ips"
		rm -f "${TMPFILE}"; release_lock; return 0
	fi

	# 解析/创建群组
	tid=$(get_table_id "${grpname}")
	if [ "${tid}" = "" ]; then
		${FLOWEYE} table add name="${grpname}" >/dev/null 2>&1
		tid=$(get_table_id "${grpname}")
		if [ "${tid}" = "" ]; then
			write_log "FAIL" "create_group_fail:${grpname}"
			rm -f "${TMPFILE}"; release_lock; return 1
		fi
	else
		# 防误覆盖:已存在、且不是本 app 之前用的群组、且非空 → 拒绝(保护系统/运营商/他人群组)
		if [ "${grpname}" != "${prev_grp}" ]; then
			cur=$(${FLOWEYE} table get id=${tid} stat=1 2>/dev/null | grep -E '^ipv4cnt=' | cut -d'=' -f2 | tr -cd '0-9')
			if [ "${cur}" != "" ] && [ "${cur}" -gt 0 ]; then
				write_log "FAIL" "group_in_use:${grpname}(${cur}ips)拒绝覆盖,请改用专用/空群组"
				rm -f "${TMPFILE}"; release_lock; return 1
			fi
		fi
	fi

	# 原子换库:清空 + 批量灌入(只清不删群组)
	${FLOWEYE} table loadfile tid=${tid} file="${TMPFILE}" clear=1 >/dev/null 2>&1
	if [ $? -ne 0 ]; then
		write_log "FAIL" "loadfile_fail:tid=${tid}"
		rm -f "${TMPFILE}"; release_lock; return 1
	fi

	count=$(wc -l < "${TMPFILE}" 2>/dev/null | tr -d ' ')
	elapsed=$(( $(date +%s) - start_time ))
	cat > "${SYNCFILE}" << EOF
grpname=${grpname}
tid=${tid}
ktype=${ktype}
service=${service}
level=${level}
precision=${precision}
days=${days}
last_sync=${now}
last_check=${now}
count=${count}
md5=${new_md5}
status=success
EOF
	write_log "SUCCESS" "${grpname}|${count}ips|${elapsed}s"
	rm -f "${TMPFILE}"
	release_lock
	return 0
}

case "$1" in
"sync"|"")
	do_sync
	;;
*)
	echo "usage: tcblock_sync sync"
	;;
esac
