Files
MeowOS-For-R3P2/patch_v2/ntpsync/meow_ntpsync
T
AskaEth 1f692ae2a7 fix(ntp): M22 r1 评审 P2×3 — 孤儿 ntpd 收尸 / 日志失败不吞 hwclock / 锁自证
r1 评审 verdict=p2-3items,三条均已按评审给的修法修掉,且都补了可证伪的回归断言。

P2-1 SIGTERM 后遗留孤儿 ntpd,之后每次静默 skip 且 rc=0
- attempt() 把子进程记进全局 NTPD_CHILD 并落盘 <锁目录>/ntpd.pid;
  新增 cleanup()(EXIT trap)先收尸子进程再释放锁;新增 on_signal()
  (INT/TERM/HUP)清理后 exit 143——旧代码的 TERM trap 只清锁、不 exit,
  被杀后还会继续跑完看门狗。
- 取代原来的 "pidof ntpd 就 skip":新增 reap_or_skip_ntpd(),用
  /proc/<pid>/cmdline 是否含本锁目录来区分「我们自己遗留的孤儿」(收尸)
  与「别人的常驻 ntpd」(让路)。崩溃后留下的 ntpd.pid 也据此校验,
  pid 被复用时不会误判成外部 daemon(不会永久 skip)。
- S45 stop 不再按陈旧 pid 文件盲杀:只从 <锁目录>/<pid> 取持有者,
  再核对 /proc/<pid>/cmdline 含 meow_ntpsync 才 kill。

P2-2 日志重定向失败导致 hwclock 根本不执行
- persist_rtc 改 ">/dev/null 2>>$LOG_FILE":hwclock 的 rc 与日志写入解耦。
- 新增 prepare_log():启动时在子 shell 里试写日志,不可写就整体退化 /dev/null。
  必须用子 shell——`:` 是 POSIX 特殊内建,它的重定向失败会让 dash 直接
  退出整个脚本(本单自测先踩到了,rc=2)。

P2-3 锁 mkdir→写 pid 有窗口,并发抑制非原子
- 锁的持有者改由 <锁目录>/<pid> 子目录自证(mkdir 原子),不再写 pid 文件;
  读到空锁时给 3s 宽限而不是立刻判死。
- 清失效锁改用 mv 到 <锁目录>.stale.$$(rename 原子),并发清锁时只有
  一个能成功,避免「抢锁失败方删掉胜方的锁」。
- 退出只删自己的锁:没有自己的 <pid> 子目录就不动锁目录。

验证
- 新增回归断言 11-16(run_tests.sh):SIGTERM 收尸/锁释放、遗留孤儿收尸、
  日志不可写仍写 RTC、锁被占用不抢、死锁可接管、pid 复用不阻断。
- 可证伪性:新套件跑旧代码(c9f0cba 的 meow_ntpsync)→ 6 条 FAIL
  (孤儿 alive、日志不可写 rc=1 且 hwclock 未执行、抢锁并发调 ntpd×3)。
  再跑「仅回退 STALE_NTPD 硬化」的变体 → pid 复用那条 FAIL
  (rc=0 但 RTC 未写,正是评审指出的静默失效)。
- dash 与 busybox ash 各 41/41 PASS,rc=0;dash -n / busybox sh -n 均 0 错。
- 时间断言放宽为负载容忍(宿主是多 agent 共享机,实测 sleep 5 能跑成 85s),
  假 ntpd 改为挂 3600s,保证上界仍能证明是看门狗掐断而非自然退出。

fw_content.conf: meow_ntpsync md5 ad324e3f→f3c3abe4,
S45meow_ntpsync md5 25454a3f→115e7592(50meow_ntpsync 未改,md5 不变)。
2026-11-06 13:34:45 +08:00

449 lines
13 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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