#!/bin/sh
#
# meow_ntpsync — 网络校时（一次性，不常驻）M22
#
# ---- 为什么需要它（2026-11-06 固件 0.8.47 实测）----
#   * 设备有 RTC：/dev/rtc0（驱动 10003000.rtc），内核开机 hctosys=1，
#     即系统时间来自 RTC；RTC 掉电保持（冷启动后不是 1970）。
#   * 但 RTC 走时偏慢且**没有任何东西纠正**：实测系统时间比 NTP 慢 49 s，
#     把系统时间人为拨错 4 h 后观察 65 s，无人纠正，RTC 也不受系统时间影响。
#   * 固件其实自带 ntp-4.2.8p14（/usr/sbin/ntpd）和 /etc/ntp.conf
#     （4 个 pool.ntp.org），但**没有任何 init 脚本/进程启动它**
#     → 根因是「有客户端、没启动」，不是「没有 NTP 客户端」。
#   * udhcpc 的 default.script 不处理 option 42，实测热点也不下发。
#
# ---- 三条路线的取舍 ----
#   ① 自写轻量 SNTP：要新增/交叉编译一个二进制，且 UDP 123 收发、超时、
#      时间戳换算都要自己写；收益与 ntpd 相同 → 放弃。
#   ② 复用固件自带 ntpd（本脚本）：零新增依赖、零体积增长，协议实现成熟，
#      -g 允许开机大步进跳时。唯一坑是 ntpd 没有超时参数（无响应会一直等），
#      所以本脚本自带看门狗 kill -9。
#   ③ 依赖 DHCP option 42：实测热点不下发，且 udhcpc 的 default.script
#      根本不读 ntpsrv → 不能作为唯一来源；但企业网可能有，
#      所以 dhcp 钩子把它当「网络就绪事件 + 可选服务器源」，而非唯一依赖。
#
# ---- 设计约束 ----
#   * 不阻塞开机：调用方（S45 / udhcpc 钩子）一律后台拉起本脚本后立即返回。
#   * 严格有界：单次 ntpd 上限 ATTEMPT_TIMEOUT 秒，-b 模式总时长受 -t 限制，
#     进程生命周期最长 ≈ -t + 2×ATTEMPT_TIMEOUT，之后自杀。
#   * 网络未就绪不硬等：先轮询默认路由，出现才尝试校时。
#   * 失败静默：只写 /var/log（tmpfs，不磨 NAND），不占 CPU、不留常驻进程。
#   * 成功必须回写 RTC（hwclock -w -u），否则下次开机电又错了。
#
# 用法:
#   meow_ntpsync -b [-t 秒] [-s "服务器 ..."]   开机模式：等默认路由 + 重试
#   meow_ntpsync [-s "服务器 ..."]              立即试一次（udhcpc bound/renew）
#   meow_ntpsync -S                             打印上次同步记录
#   meow_ntpsync -f ...                         忽略「1 h 内已同步」短路
#
# 退出码: 0 = 时间可信（本次同步成功，或 1 h 内已同步过）；1 = 失败/超时；2 = 用法错误
#
# 以下变量可由环境覆盖——仅供宿主单测注入，设备上不设置，走真实路径：
#   MEOW_NTPD / MEOW_HWCLOCK / MEOW_NETCHECK / MEOW_NTPSYNC_ATTEMPT_TIMEOUT
#   MEOW_NTPSYNC_LOG / MEOW_NTPSYNC_STATE / MEOW_NTPSYNC_LOCK / MEOW_NTPSYNC_PIDOF

NTPD="${MEOW_NTPD:-/usr/sbin/ntpd}"
HWCLOCK="${MEOW_HWCLOCK:-/sbin/hwclock}"
NETCHECK="${MEOW_NETCHECK:-}"

LOG_FILE="${MEOW_NTPSYNC_LOG:-/var/log/meow_ntpsync.log}"
STATE_FILE="${MEOW_NTPSYNC_STATE:-/tmp/meow_ntpsync.state}"
LOCK_DIR="${MEOW_NTPSYNC_LOCK:-/tmp/meow_ntpsync.lock}"

# 内置 IP 兜底（不依赖 DNS，国内可达）：阿里 / 腾讯 / Cloudflare
FALLBACK_IP="203.107.6.88 120.25.115.20 162.159.200.1"
# DNS 名兜底：内置 IP 全废（被墙/下线）时最后再试一次
FALLBACK_DNS="0.pool.ntp.org 1.pool.ntp.org"

SYNC_TTL=3600                                # 1 h 内已同步过就短路
ATTEMPT_TIMEOUT="${MEOW_NTPSYNC_ATTEMPT_TIMEOUT:-15}"  # 单次 ntpd 上限（实测 iburst 8 s 内返回）
NTPD_PIDOF="${MEOW_NTPSYNC_PIDOF:-pidof ntpd}"         # 找 ntpd 进程（单测可注入）
BOOT_TOTAL=120                               # -b 模式总时长上限

usage() {
	cat <<EOF
用法: meow_ntpsync [-b] [-t 秒] [-s "服务器 ..."] [-f] [-q]
      meow_ntpsync -S
  -b  开机模式：先等默认路由出现，再校时，总时长受 -t 限制（默认 ${BOOT_TOTAL}s）
  -t  开机模式总时长（秒）
  -s  DHCP option 42 下发的 NTP 服务器（空格分隔），优先于内置 IP
  -f  强制同步，忽略「1 h 内已同步」短路
  -q  不写日志
  -S  打印上次同步记录
EOF
}

log() {
	[ -n "$QUIET" ] && return 0
	printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$LOG_FILE" 2>/dev/null
	return 0
}

# 网络就绪 = 存在默认路由。不 ping、不解析 DNS，只查内核路由表（零网络开销）。
net_ready() {
	if [ -n "$NETCHECK" ]; then
		$NETCHECK >/dev/null 2>&1
		return $?
	fi
	if [ -r /proc/net/route ]; then
		awk 'NR>1 && $2=="00000000" {found=1; exit} END{exit !found}' \
			/proc/net/route 2>/dev/null && return 0
	fi
	if command -v ip >/dev/null 2>&1; then
		ip route 2>/dev/null | grep -q '^default ' && return 0
	fi
	return 1
}

synced_recently() {
	[ -f "$STATE_FILE" ] || return 1
	s_epoch=$(sed -n 's/^epoch=//p' "$STATE_FILE" 2>/dev/null | head -n 1)
	case "$s_epoch" in
		''|*[!0-9]*) return 1 ;;
	esac
	now=$(date +%s)
	age=$((now - s_epoch))
	# 时钟被往回拨时 age<0，视为未同步
	[ "$age" -ge 0 ] && [ "$age" -lt "$SYNC_TTL" ]
}

LOCK_HELD=""
STALE_NTPD=""
NTPD_CHILD=""

# 锁的持有者由「锁目录下的 <pid> 子目录」自证：mkdir 原子，且进程还在就必能 kill -0。
# 不用 pid 文件——写文件有窗口，读到空 pid 会被误判成「持有者已死」。
lock_owner() {
	for d in "$LOCK_DIR"/*; do
		[ -d "$d" ] || continue
		echo "${d##*/}"
		return 0
	done
	return 1
}

lock_take() {
	mkdir "$LOCK_DIR" 2>/dev/null || return 1
	chmod 700 "$LOCK_DIR" 2>/dev/null
	mkdir "$LOCK_DIR/$$" 2>/dev/null
	LOCK_HELD=1
	return 0
}

lock_acquire() {
	if [ -d "$LOCK_DIR" ]; then
		# 对方可能刚 mkdir、还没建 <pid> 子目录：给 3 s 宽限，期间不抢
		i=0
		other=""
		while [ "$i" -lt 3 ]; do
			other=$(lock_owner) && break
			sleep 1
			i=$((i + 1))
		done
		case "$other" in
			''|*[!0-9]*) other=0 ;;
		esac
		if [ "$other" -gt 0 ] && kill -0 "$other" 2>/dev/null; then
			return 1
		fi
		# 持有者确实不在了（或宽限期满仍空）→ 用它遗留的 ntpd 子进程记录收尸
		STALE_NTPD=$(cat "$LOCK_DIR/ntpd.pid" 2>/dev/null)
		# rename 是原子的：并发清锁时只有一个能 mv 成功，避免「失败方删掉胜方的锁」
		if mv "$LOCK_DIR" "$LOCK_DIR.stale.$$" 2>/dev/null; then
			[ -z "$STALE_NTPD" ] &&
				STALE_NTPD=$(cat "$LOCK_DIR.stale.$$/ntpd.pid" 2>/dev/null)
			rm -rf "$LOCK_DIR.stale.$$" 2>/dev/null
		fi
	fi
	lock_take
}

# 只删自己建立的锁：没有自己的 <pid> 子目录就说明锁已不是我们的，别动
lock_release() {
	[ "$LOCK_HELD" = 1 ] || return 0
	[ -d "$LOCK_DIR/$$" ] && rm -rf "$LOCK_DIR" 2>/dev/null
	LOCK_HELD=""
	return 0
}

# ntpd 子进程是否由本脚本拉起：cmdline 里的 -c 指向我们的锁目录
ntpd_is_ours() {
	cmd=$(tr '\0' ' ' <"/proc/$1/cmdline" 2>/dev/null)
	case "$cmd" in
		*"$LOCK_DIR"*) return 0 ;;
	esac
	return 1
}

reap_pid() {
	log "reaping stale ntpd child pid=$1"
	kill "$1" 2>/dev/null
	sleep 1
	kill -9 "$1" 2>/dev/null
}

# 只对「别人的常驻 ntpd」让路；自己遗留的孤儿必须收尸，
# 否则孤儿会被 pidof 认成「已有 ntpd 在跑」→ 之后每次都静默 skip + rc=0。
reap_or_skip_ntpd() {
	# 先按崩溃时留下的 ntpd.pid 收尸。只认 cmdline 自证：pid 可能已被复用，
	# 复用后的进程「不是我们的」并不等于「它是别人的 ntpd」，此时必须忽略它，
	# 否则又会退化成永久 skip。
	case "$STALE_NTPD" in
		''|*[!0-9]*) ;;
		*)
			if kill -0 "$STALE_NTPD" 2>/dev/null && ntpd_is_ours "$STALE_NTPD"; then
				reap_pid "$STALE_NTPD"
			fi
			;;
	esac

	remaining=""
	seen=" "
	for p in $($NTPD_PIDOF 2>/dev/null); do
		case "$p" in
			''|*[!0-9]*) continue ;;
		esac
		case "$seen" in
			*" $p "*) continue ;;
		esac
		seen="$seen$p "
		kill -0 "$p" 2>/dev/null || continue
		if ntpd_is_ours "$p"; then
			reap_pid "$p"
		else
			remaining="$remaining $p"
		fi
	done
	if [ -n "$remaining" ]; then
		log "skip: a foreign ntpd is already running:$remaining"
		exit 0
	fi
	return 0
}

# 退出清理：先收尸自己的 ntpd 子进程，再只删自己的锁。
# 顺序很重要——子进程在锁目录里登记了 ntpd.pid，锁删早了就丢线索。
cleanup() {
	if [ -n "$NTPD_CHILD" ] && kill -0 "$NTPD_CHILD" 2>/dev/null; then
		kill "$NTPD_CHILD" 2>/dev/null
		n=0
		while [ "$n" -lt 3 ] && kill -0 "$NTPD_CHILD" 2>/dev/null; do
			sleep 1
			n=$((n + 1))
		done
		kill -9 "$NTPD_CHILD" 2>/dev/null
		wait "$NTPD_CHILD" 2>/dev/null
	fi
	NTPD_CHILD=""
	lock_release
}

# 被信号打断时：清理后必须 exit，否则 ash 执行完 trap 会继续往下跑
on_signal() {
	cleanup
	exit 143
}

clock_sane() {
	y=$(date +%Y 2>/dev/null)
	case "$y" in
		''|*[!0-9]*) return 1 ;;
	esac
	[ "$y" -ge 2020 ] && [ "$y" -le 2100 ]
}

record_success() {
	now=$(date +%s)
	{
		echo "epoch=$now"
		echo "date=$(date '+%Y-%m-%d %H:%M:%S')"
		echo "servers=$1"
	} >"$STATE_FILE" 2>/dev/null
}

# 日志目录不可写时整体退化为 /dev/null。
# 关键：重定向失败会让**命令本身不执行**，所以不能让「日志写不进去」
# 连带把 hwclock / ntpd 也一起废掉（P2-2）。
# 又：`:` 是 POSIX 特殊内建，它的重定向失败会让 dash 直接退出整个脚本，
# 所以试写必须在子 shell 里做，把失败局限在子 shell 内。
prepare_log() {
	( : >>"$LOG_FILE" ) 2>/dev/null || LOG_FILE=/dev/null
}

# RTC 存的是 UTC（实测 /sys/class/rtc/rtc0/since_epoch == date +%s），必须 -u
persist_rtc() {
	# hwclock 的 rc 只反映「RTC 写没写成」，与日志能否写入解耦
	if "$HWCLOCK" -w -u >/dev/null 2>>"$LOG_FILE"; then
		log "rtc written (hwclock -w -u)"
		return 0
	fi
	log "rtc write FAILED (hwclock -w -u)"
	return 1
}

# 生成 ntpd 临时配置。iburst 是必须的：实测不加 iburst 时首个 poll 要等 ~64 s，
# -q 根本来不及取到时间（加 iburst 后 8 s 内返回）。
gen_conf() {
	for s in $1; do
		echo "server $s iburst"
	done >"$LOCK_DIR/ntp.conf" 2>/dev/null
}

# 单次尝试。ntpd 无响应时不会自己退出（实测无服务器时永久存活），
# 所以用轮询 + kill -9 兜底；成功与否只看退出码。
#
# NTPD_CHILD 是全局的：本脚本被 SIGTERM/SIGINT 打断时，trap 靠它把子进程杀掉。
# 否则 ntpd 会孤儿化（ppid 已死），此后 pidof 把它当成「已有 ntpd 在跑」。
attempt() {
	servers="$1"
	[ -n "$servers" ] || return 1
	gen_conf "$servers" || return 1

	"$NTPD" -n -q -g -c "$LOCK_DIR/ntp.conf" </dev/null >>"$LOG_FILE" 2>&1 &
	NTPD_CHILD=$!
	# 落盘一份，供崩溃（SIGKILL/掉电）后下一次运行收尸
	echo "$NTPD_CHILD" >"$LOCK_DIR/ntpd.pid" 2>/dev/null
	pid=$NTPD_CHILD
	waited=0
	while [ "$waited" -lt "$ATTEMPT_TIMEOUT" ]; do
		kill -0 "$pid" 2>/dev/null || break
		sleep 1
		waited=$((waited + 1))
	done

	if kill -0 "$pid" 2>/dev/null; then
		kill -9 "$pid" 2>/dev/null
		wait "$pid" 2>/dev/null
		NTPD_CHILD=""
		log "attempt timeout after ${waited}s (servers: $servers)"
		return 1
	fi
	wait "$pid" 2>/dev/null
	rc=$?
	NTPD_CHILD=""
	if [ "$rc" -ne 0 ]; then
		log "attempt failed rc=$rc (servers: $servers)"
		return 1
	fi
	return 0
}

trim_log() {
	[ -f "$LOG_FILE" ] || return 0
	sz=$(wc -c <"$LOG_FILE" 2>/dev/null)
	case "$sz" in
		''|*[!0-9]*) return 0 ;;
	esac
	# /var/log 是 tmpfs，只防无限增长；保留最后 200 行
	[ "$sz" -gt 65536 ] && tail -n 200 "$LOG_FILE" >"$LOG_FILE.tmp" 2>/dev/null &&
		mv -f "$LOG_FILE.tmp" "$LOG_FILE" 2>/dev/null
	return 0
}

# 一轮 = 先打「DHCP 服务器 + 内置 IP」（不需要 DNS），再打 DNS 名
sync_round() {
	servers="$EXTRA $FALLBACK_IP"
	trim_log

	if attempt "$servers"; then
		if clock_sane; then
			record_success "$servers"
			log "sync OK (servers: $servers)"
			persist_rtc
			return 0
		fi
		log "sync reported ok but clock still insane, ignore"
	fi

	if attempt "$FALLBACK_DNS"; then
		if clock_sane; then
			record_success "$FALLBACK_DNS"
			log "sync OK (dns: $FALLBACK_DNS)"
			persist_rtc
			return 0
		fi
		log "dns sync reported ok but clock still insane, ignore"
	fi

	return 1
}

MODE=single
TOTAL=$BOOT_TOTAL
EXTRA=""
FORCE=0
QUIET=""

while [ $# -gt 0 ]; do
	case "$1" in
		-b) MODE=boot ;;
		-t) TOTAL="$2"; shift ;;
		-s) EXTRA="$2"; shift ;;
		-f) FORCE=1 ;;
		-q) QUIET=1 ;;
		-S) MODE=status ;;
		-h|--help) usage; exit 0 ;;
		*)
			echo "meow_ntpsync: unknown option: $1" >&2
			usage >&2
			exit 2
			;;
	esac
	shift
done

case "$TOTAL" in
	''|*[!0-9]*)
		echo "meow_ntpsync: -t 必须是整数秒" >&2
		exit 2
		;;
esac

if [ "$MODE" = status ]; then
	if [ -f "$STATE_FILE" ]; then
		cat "$STATE_FILE"
	else
		echo "never synced"
	fi
	exit 0
fi

prepare_log

if [ "$FORCE" -eq 0 ] && synced_recently; then
	log "skip: synced within ${SYNC_TTL}s"
	exit 0
fi

if ! lock_acquire; then
	log "skip: another meow_ntpsync is running"
	exit 0
fi
trap 'cleanup' EXIT
trap 'on_signal' INT TERM HUP

# 让路给「别人的常驻 ntpd」；自己遗留的孤儿先收尸（P2-1）
reap_or_skip_ntpd

if [ "$MODE" = boot ]; then
	deadline=$(( $(date +%s) + TOTAL ))
	while :; do
		if net_ready; then
			log "network ready, syncing"
			sync_round && exit 0
		fi
		[ "$(date +%s)" -ge "$deadline" ] && break
		sleep 5
	done
	log "give up: no sync within ${TOTAL}s"
	exit 1
fi

# 单次模式（udhcpc 钩子）：此刻租约刚拿到，路由已装好，直接试
if ! net_ready; then
	log "single: network not ready, skip"
	exit 1
fi
sync_round && exit 0
exit 1
