#!/bin/sh
#
# tcblock_sync - TrapCloud 精准封堵:按分类条件拉去重 IP 灌入 Panabit 群组
#
# 用法:
#   tcblock_sync sync        # 按 tcblock.conf 当前条件同步一次
#
# 机制:
#   - pacu -k 拉 /api/export?key=..&type=..&service=..&level=..&precision=..&hours=..&source=..&format=txt
#   - 可选爬虫诱捕通道(crawler_enable=1):再拉 /api/crawler-export 灌入独立第二群组
#   - 校验返回为 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"
CRAWLERFILE="${CONFDIR}/crawler.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"
CURL_TIMEOUT=60
MAX_LOG=50

# 下载节点多地址容错(2026-09-24):dl.trapcloud.cn 三线 IP 依次尝试,名单下载再兜底主站 www。
# 原来单 BASE_URL,解析到的线路一不通就永远 ret=28,见 tcblock_net 头部说明。
NET_TMP="${TMPDIR}"
for _nl in "$(dirname "$0")/tcblock_net" "${RAMROOT}/bin/tcblock_net" "${PGPATH}/app/${APPNAME}/bin/tcblock_net"; do
	[ -f "${_nl}" ] && { . "${_nl}"; break; }
done
# 组件缺失(残缺安装)时退回旧的单地址行为,保证同步不因此整体瘫痪
if ! command -v net_fetch >/dev/null 2>&1; then
	net_fetch()
	{
		NET_EP="dns"; NET_TRIED=""
		NET_HTTP=$(${CURL} -k -s -m "$3" -o "$2" -w "%{http_code}" "https://dl.trapcloud.cn:64005$1" 2>/dev/null)
		NET_RET=$?
		[ "${NET_RET}" = "0" ] || { NET_TRIED="默认:${NET_RET}:${NET_HTTP}"; return 1; }
	}
	ep_label() { echo "$1"; }
fi

# ============================================================
# 🔴 配置读取:绝不 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")
	source=$(in_set "$(conf_get source)" "all honeypot telescope" "all")
	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"
	# 爬虫诱捕通道(独立第二群组,与攻击封堵组分开)
	crawler_enable=$(in_set "$(conf_get crawler_enable)" "0 1" "0")
	crawler_mode=$(in_set "$(conf_get crawler_mode)" "safe full" "safe")
	crawler_days=$(num_in "$(conf_get crawler_days)" 1 30 7)
	crawler_grpname=$(conf_get crawler_grpname | tr -cd 'A-Za-z0-9_-' | cut -c1-64)
	[ -z "${crawler_grpname}" ] && crawler_grpname="TrapCloud_crawler"
}
load_conf

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

# 🔴 写日志必须串行(2026-09-29)
# 手动同步(CGI)、守护进程、appctrl 都会写 sync.log。原来"追加 → tail 到固定名 .tmp → mv"
# 不加锁:两个进程同时截断会共用同一个 .tmp(内容互相覆盖),或一方 mv 时抹掉另一方刚追加的行。
# 这里用 mkdir 原子锁把「追加+截断」包成一步;锁放 /tmp(不在 flash 上反复建删)。
# 持锁方若被强杀会留下锁目录 → 锁里记了 pid,持有者已不在即接管(日志不值得为它卡住同步)。
LOGLOCK="/tmp/tcblock.synclog.lk"
write_log()
{
	# write_log STATUS msg
	mkdir -p "${CONFDIR}"
	_lw=0
	while ! mkdir "${LOGLOCK}" 2>/dev/null; do
		_lw=$((_lw + 1))
		# 只有持锁进程已经不在(被强杀留下的僵死锁)才立即接管;锁里连 pid 都没有
		# (刚好死在 mkdir 与写 pid 之间)则等满 10 秒兜底接管。持有者活着就老实排队。
		_lp=$(cat "${LOGLOCK}/pid" 2>/dev/null | tr -cd '0-9')
		if { [ -n "${_lp}" ] && ! kill -0 "${_lp}" 2>/dev/null; } || [ ${_lw} -ge 10 ]; then
			rm -rf "${LOGLOCK}" 2>/dev/null; mkdir "${LOGLOCK}" 2>/dev/null
			break
		fi
		sleep 1
	done
	echo $$ > "${LOGLOCK}/pid" 2>/dev/null
	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 -f "${LOGFILE}.tmp.$$" "${LOGFILE}"
		rm -f "${LOGFILE}.tmp.$$" 2>/dev/null
	fi
	rm -rf "${LOGLOCK}" 2>/dev/null
}

# 🔴 原子锁:必须用 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

	# 没拿到:持锁进程已经不在了 → 立即回收(2026-09-24)
	# 升级/重启 APP 时 appctrl stop 会强杀正在等超时的同步进程,trap 来不及释放锁,
	# 过去要干等 30 分钟,期间「立即同步」一直 locked(诸暨现场升级后实际踩到)。
	# pid 为空(对方刚 mkdir、还没写 pid 的瞬间)不算死锁;pid 被系统复用给别的进程
	# 则看 cmdline 里有没有 tcblock_sync。
	# 无 /proc 时判断不了进程死活,不启用此规则(否则每把锁都会被当成死锁)。
	# 删锁前再读一次 pid:读到 pid 之后原持有者可能已释放、别人已拿到新锁,不能误删别人的。
	lp=$(cat "${LOCKDIR}/pid" 2>/dev/null | tr -cd '0-9')
	if [ -n "${lp}" ] && [ -d /proc/self ] && ! tr '\000' ' ' 2>/dev/null < "/proc/${lp}/cmdline" | grep -q "tcblock_sync" \
		&& [ "$(cat "${LOCKDIR}/pid" 2>/dev/null | tr -cd '0-9')" = "${lp}" ]; then
		rm -rf "${LOCKDIR}" 2>/dev/null
		if mkdir "${LOCKDIR}" 2>/dev/null; then
			_lock_taken
			return 0
		fi
		return 1
	fi

	# 锁目录里 pid、ts 都没有:持锁方要么刚 mkdir 还没写(微秒级),要么在 mkdir 与写入之间
	# 或释放锁删到一半时被强杀,留下空目录 —— 上面的 pid 规则和下面的 30 分钟规则都判不了,
	# 会永久 locked(诸暨现场 13:02 留下空锁目录,卡了 5 个多小时;该机 /tmp 在磁盘上,重启也不清)。
	# 等 2 秒复查,仍为空即回收。
	if [ -z "${lp}" ] && [ ! -f "${LOCKDIR}/ts" ]; then
		sleep 2
		if [ -d "${LOCKDIR}" ] && [ ! -f "${LOCKDIR}/pid" ] && [ ! -f "${LOCKDIR}/ts" ]; then
			rm -rf "${LOCKDIR}" 2>/dev/null
			if mkdir "${LOCKDIR}" 2>/dev/null; then
				_lock_taken
				return 0
			fi
		fi
		return 1
	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}}'
}

# ============================================================
# 爬虫诱捕通道:独立第二群组(2026-08-29)
#   - 拉 /api/crawler-export?format=txt(服务端默认只出「可阻断」IP,不含官方搜索引擎爬虫,
#     封堵不影响收录);crawler_mode=full 时带 include_official=1(含官方爬虫,用户显式选择)。
#   - 群组/状态(crawler.state)/防误覆盖 与主通道彼此独立;不计下载配额。
#   - 空名单是合法结果(近期无可封爬虫)→ 清空群组;凭首行 "# TrapCloud" 头与错误页区分。
#   - 必须在持有主锁时调用(do_sync 内部)。
# ============================================================
do_crawler_sync()
{
	c_start=$(date +%s)
	CTMP="${TMPDIR}/crawler.txt"
	c_path="/api/crawler-export?key=${apikey}&format=txt&days=${crawler_days}"
	[ "${crawler_mode}" = "full" ] && c_path="${c_path}&include_official=1"

	# 主站 www 没有 crawler-export 路由,只在 dl 各线路间容错
	if ! net_fetch "${c_path}" "${CTMP}" ${CURL_TIMEOUT} 0; then
		write_log "FAIL" "crawler:curl_err:ret=${NET_RET},tried=${NET_TRIED}"
		rm -f "${CTMP}"; return 1
	fi
	if [ "${NET_HTTP}" != "200" ]; then
		write_log "FAIL" "crawler:http_${NET_HTTP}"
		rm -f "${CTMP}"; return 1
	fi
	# 有效性:首行必须是 TrapCloud 头(空名单也带头,可与错误页区分)
	if ! head -1 "${CTMP}" 2>/dev/null | grep -q "^# TrapCloud"; then
		h1=$(head -1 "${CTMP}" 2>/dev/null | tr '"' "'" | cut -c1-80)
		write_log "FAIL" "crawler:bad_format:${h1}"
		rm -f "${CTMP}"; return 1
	fi
	grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "${CTMP}" > "${CTMP}.clean"
	mv "${CTMP}.clean" "${CTMP}"
	c_count=$(wc -l < "${CTMP}" 2>/dev/null | tr -d ' ')
	[ -z "${c_count}" ] && c_count=0

	c_new_md5=$(calc_md5 "${CTMP}")
	c_old_md5=""; c_prev_grp=""
	if [ -f "${CRAWLERFILE}" ]; then
		c_old_md5=$(grep '^md5=' "${CRAWLERFILE}" 2>/dev/null | cut -d'=' -f2)
		c_prev_grp=$(grep '^grpname=' "${CRAWLERFILE}" 2>/dev/null | cut -d'=' -f2)
	fi
	c_now=$(date +%s)
	if [ "${c_new_md5}" = "${c_old_md5}" ] && [ "${c_old_md5}" != "" ] && [ "${crawler_grpname}" = "${c_prev_grp}" ]; then
		grep -v '^last_check=' "${CRAWLERFILE}" > "${CRAWLERFILE}.tmp" 2>/dev/null
		echo "last_check=${c_now}" >> "${CRAWLERFILE}.tmp"
		mv "${CRAWLERFILE}.tmp" "${CRAWLERFILE}"
		write_log "SKIP" "crawler:no_change,${c_count}ips"
		rm -f "${CTMP}"; return 0
	fi

	ctid=$(get_table_id "${crawler_grpname}")
	if [ "${ctid}" = "" ]; then
		${FLOWEYE} table add name="${crawler_grpname}" >/dev/null 2>&1
		ctid=$(get_table_id "${crawler_grpname}")
		if [ "${ctid}" = "" ]; then
			write_log "FAIL" "crawler:create_group_fail:${crawler_grpname}"
			rm -f "${CTMP}"; return 1
		fi
	else
		# 防误覆盖:换到一个已存在且非空、又不是本通道之前用的群组 → 拒绝
		if [ "${crawler_grpname}" != "${c_prev_grp}" ]; then
			ccur=$(${FLOWEYE} table get id=${ctid} stat=1 2>/dev/null | grep -E '^ipv4cnt=' | cut -d'=' -f2 | tr -cd '0-9')
			if [ "${ccur}" != "" ] && [ "${ccur}" -gt 0 ]; then
				write_log "FAIL" "crawler:group_in_use:${crawler_grpname}(${ccur}ips)拒绝覆盖,请改用专用/空群组"
				rm -f "${CTMP}"; return 1
			fi
		fi
	fi

	${FLOWEYE} table loadfile tid=${ctid} file="${CTMP}" clear=1 >/dev/null 2>&1
	lrc=$?
	if [ ${lrc} -ne 0 ] && [ "${c_count}" = "0" ]; then
		# 空名单:部分固件对空文件 loadfile 报错,退回 table clear(只清空,群组保留)
		${FLOWEYE} table clear tid=${ctid} >/dev/null 2>&1
		ccur=$(${FLOWEYE} table get id=${ctid} stat=1 2>/dev/null | grep -E '^ipv4cnt=' | cut -d'=' -f2 | tr -cd '0-9')
		[ "${ccur}" = "0" ] && lrc=0
	fi
	if [ ${lrc} -ne 0 ]; then
		write_log "FAIL" "crawler:loadfile_fail:tid=${ctid}"
		rm -f "${CTMP}"; return 1
	fi

	c_elapsed=$(( $(date +%s) - c_start ))
	cat > "${CRAWLERFILE}" << EOF
grpname=${crawler_grpname}
tid=${ctid}
mode=${crawler_mode}
days=${crawler_days}
last_sync=${c_now}
last_check=${c_now}
count=${c_count}
md5=${c_new_md5}
status=success
EOF
	write_log "SUCCESS" "crawler:${crawler_grpname}|${c_count}ips|${c_elapsed}s"
	rm -f "${CTMP}"
	return 0
}

# ============================================================
# 核心同步
# ============================================================
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

	# 返回 2(而非 1)让界面区分「上一轮还在跑」与「同步失败」
	acquire_lock || { write_log "SKIP" "locked(上一次同步仍在进行中)"; return 2; }
	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))
	path="/api/export?key=${apikey}&type=${ktype}&service=${service}&level=${level}&precision=${precision}&hours=${hours}&source=${source}&format=txt"

	if ! net_fetch "${path}" "${TMPFILE}" ${CURL_TIMEOUT} 1; then
		write_log "FAIL" "curl_err:ret=${NET_RET},tried=${NET_TRIED}"
		rm -f "${TMPFILE}"; release_lock; return 1
	fi
	http_code="${NET_HTTP}"
	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,via=$(ep_label "${NET_EP}")"
		rm -f "${TMPFILE}"
		crawler_rc=0
		[ "${crawler_enable}" = "1" ] && { do_crawler_sync || crawler_rc=1; }
		release_lock; return ${crawler_rc}
	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|via=$(ep_label "${NET_EP}")"
	rm -f "${TMPFILE}"
	crawler_rc=0
	[ "${crawler_enable}" = "1" ] && { do_crawler_sync || crawler_rc=1; }
	release_lock
	return ${crawler_rc}
}

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