You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

366 lines
16 KiB

#!/bin/sh
# dpworld-network-apply.sh — firmware-safe network apply script
# ---------------------------------------------------------------------------
# 목적:
# dpworldapp 또는 WebConfigurator가 /home/root/network/에 생성한 네트워크
# 렌더 파일을 systemd-networkd, wpa_supplicant, WiFi country 설정에
# 동기화한다.
#
# 배포 위치:
# firmware image: /usr/bin/dpworld-network-apply.sh
# file mode: root:root, 0755, LF, POSIX /bin/sh
#
# 호출:
# dpworld-network-apply.sh
# on-demand 적용. /run 동기화 후 networkctl reload/reconfigure,
# WPA 변경 시 wpa_cli reconfigure를 수행한다.
#
# dpworld-network-apply.sh --boot
# 부팅 seed. /home/root/network가 비어 있으면
# /etc/dpworld/network-defaults를 복사하고, /run/systemd/network를
# 먼저 동기화한다. networkd가 이미 active인 boot-order race에서는
# live address reconcile까지 수행한다.
#
# dpworld-network-apply.sh --country-now
# 호환 인자. 이 빌드 제안본에서는 live module unload를 하지 않는다.
# country는 modprobe.d에 기록하고 reboot-required marker로 처리한다.
#
# 안전 정책:
# - 실행 가능한 modprobe -r 경로를 두지 않는다. QCA6490/cnss_pci에서
# module unload가 hang과 watchdog reset으로 이어진 이력이 있기 때문이다.
# - country 변경은 reboot-deferred가 기본이다.
# - reboot-required 판단은 wpa_cli get country가 아니라
# /sys/module/wlan/parameters/country_code 또는 iw reg get 기준으로 한다.
# - set -e를 쓰지 않는다. 실패한 외부 명령은 로그에 남기고 가능한 후속
# 처리를 계속한다.
# - 기존 렌더 파일이 있으면 /etc/dpworld/network-defaults로 덮어쓰지 않는다.
#
# 상태 파일:
# /opt/dpworld-network/apply.log 적용 로그. 512KB 초과 시 apply.log.1로 자체 rotate
# /opt/dpworld-network/applied/ 마지막 적용 기준선
# /opt/dpworld-network/reboot-required country 재부팅 필요 marker
# /run/dpworld-network/apply.lock 중복 실행 방지 lock
# /run/dpworld-network/country-now 호환 marker
#
# 한계:
# eth1 IP 변경 후 confirm/rollback 같은 세션 보존 로직은 이 셸 스크립트가
# 담당하지 않는다. WebConfigurator apply engine이 있으면 그쪽에서 처리한다.
# standalone으로 eth1 주소를 바꿀 때는 콘솔 또는 대체 접속 경로를 확보한다.
#
# POSIX /bin/sh (BusyBox ash). bashism 금지.
set -u
# ─────────────────────────────── 인자 ────────────────────────────────
MODE_BOOT=0
COUNTRY_NOW=0
for a in "$@"; do
case "$a" in
--boot) MODE_BOOT=1 ;;
--country-now) COUNTRY_NOW=1 ;;
esac
done
MODE_NAME=$([ "$MODE_BOOT" -eq 1 ] && echo boot || echo ondemand)
# ─────────────────────────────── 경로 ────────────────────────────────
NETWORK_DIR="/home/root/network"
DEFAULT_DIR="/etc/dpworld/network-defaults"
RUNTIME_DIR="/run/systemd/network"
MODPROBE_CONF="/etc/modprobe.d/10-wlan.conf"
STATE_DIR="/opt/dpworld-network" # 영속(/opt, After=local-fs.target 보장) — shadow/로그/마커
SHADOW_DIR="${STATE_DIR}/applied"
LOG_FILE="${STATE_DIR}/apply.log"
REBOOT_MARK="${STATE_DIR}/reboot-required"
RUN_STATE_DIR="/run/dpworld-network" # tmpfs — 락/일회성 마커
LOCK_DIR="${RUN_STATE_DIR}/apply.lock"
LOCK_PID="${LOCK_DIR}/pid"
COUNTRY_NOW_MARK="${RUN_STATE_DIR}/country-now" # 엔진 호환: 있으면 country-now
NET_RENDERS="10-wlan0.network 10-eth0.network 10-eth1.network"
WPA_RENDER="${NETWORK_DIR}/wpa_supplicant-wlan0.conf"
COUNTRY_RENDER="${NETWORK_DIR}/wifi-country-code"
PERSIST_JSON="${NETWORK_DIR}/network_config.json"
ROOTFS_MIN_KB=20480
LOG_MAX_BYTES=524288
# 엔진 마커가 있으면 country-now 로 승격 (공존 호환)
[ -f "$COUNTRY_NOW_MARK" ] && COUNTRY_NOW=1
# ──────────────────────────── 로깅 / 실행 ─────────────────────────────
log() {
up=$(cut -d' ' -f1 /proc/uptime 2>/dev/null || echo '?') # RTC 불신뢰 → uptime 병기
line="[$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null) up=${up}s ${MODE_NAME}] $*"
echo "$line" >&2
if [ -d "$STATE_DIR" ]; then
if [ -f "$LOG_FILE" ] && [ "$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)" -gt "$LOG_MAX_BYTES" ]; then
mv -f "$LOG_FILE" "${LOG_FILE}.1" 2>/dev/null || true
fi
echo "$line" >> "$LOG_FILE" 2>/dev/null || true
fi
}
run() { # 외부 명령 + rc 로깅. 절대 스크립트를 중단시키지 않음.
"$@"; rc=$?
[ "$rc" -ne 0 ] && log " cmd rc=${rc}: $*"
return "$rc"
}
seed_defaults_if_empty() {
if ! run mkdir -p "$NETWORK_DIR"; then
log " defaults: cannot create ${NETWORK_DIR} - skip"
return 0
fi
if find "$NETWORK_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | read -r _; then
return 0
fi
if [ ! -d "$DEFAULT_DIR" ]; then
log " defaults: ${NETWORK_DIR} empty but ${DEFAULT_DIR} missing - skip"
return 0
fi
if run cp -a "${DEFAULT_DIR}/." "$NETWORK_DIR/"; then
log " defaults: seeded ${NETWORK_DIR} from ${DEFAULT_DIR}"
else
log " defaults: failed to seed ${NETWORK_DIR} from ${DEFAULT_DIR}"
fi
}
# ──────────────────────────── 헬퍼 ───────────────────────────────────
runtime_target() {
case "$1" in
10-wlan0.network) echo "${RUNTIME_DIR}/05-dpworld-wlan0.network" ;;
10-eth0.network) echo "${RUNTIME_DIR}/05-dpworld-eth0.network" ;;
10-eth1.network) echo "${RUNTIME_DIR}/05-dpworld-eth1.network" ;;
esac
}
iface_of() {
case "$1" in
10-wlan0.network) echo wlan0 ;;
10-eth0.network) echo eth0 ;;
10-eth1.network) echo eth1 ;;
esac
}
render_addr() { # 렌더 파일의 static Address (없으면 빈값=DHCP)
sed -n 's#^[[:space:]]*Address[[:space:]]*=[[:space:]]*\([0-9][0-9.]*\)/.*#\1#p' "$1" 2>/dev/null | head -n1
}
live_addr() {
ip -4 -o addr show dev "$1" 2>/dev/null | awk '{split($4,a,"/"); print a[1]}' | head -n1
}
desired_country() {
cc=""
[ -r "$COUNTRY_RENDER" ] && cc=$(tr -d ' \r\n\t' < "$COUNTRY_RENDER" 2>/dev/null | tr 'a-z' 'A-Z')
if [ -z "$cc" ] && [ -r "$PERSIST_JSON" ]; then
cc=$(sed -n 's/.*"country_code"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PERSIST_JSON" 2>/dev/null \
| head -n1 | tr -d ' \r\n\t' | tr 'a-z' 'A-Z')
fi
echo "$cc"
}
valid_cc() { case "$1" in [A-Z][A-Z]) return 0 ;; *) return 1 ;; esac; }
modprobe_country() {
[ -r "$MODPROBE_CONF" ] || return 0
sed -n 's/^[[:space:]]*options[[:space:]]\+wlan[[:space:]]\+country_code=\([A-Za-z]\{2\}\).*$/\1/p' \
"$MODPROBE_CONF" 2>/dev/null | head -n1 | tr 'a-z' 'A-Z'
}
live_country() { # 라디오 실효 country (ground truth). 모듈 로드 파라미터 우선, iw reg 폴백.
# ※ wpa_cli get country 는 wpa conf 렌더값일 뿐 라디오 실효값이 아님 → 사용 금지(거짓 양성: 오류②).
c=""
[ -r /sys/module/wlan/parameters/country_code ] && \
c=$(tr -d ' \r\n\t' < /sys/module/wlan/parameters/country_code 2>/dev/null | tr 'a-z' 'A-Z')
if { [ -z "$c" ] || [ "$c" = "00" ] || [ "$c" = "(NULL)" ]; } && command -v iw >/dev/null 2>&1; then
c=$(iw reg get 2>/dev/null | sed -n 's/^country \([A-Z][A-Z]\):.*/\1/p' | grep -v '^00$' | head -n1)
fi
echo "$c"
}
note_country_now_deferred() {
log " country-now requested, but live module unload is disabled on this hardware; reboot-required 유지"
rm -f "$COUNTRY_NOW_MARK" 2>/dev/null || true
}
# ──────────────────────── 락 (mkdir 원자 + PID stale 회수) ─────────────
acquire_lock() {
i=0
while [ "$i" -lt 12 ]; do
if mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$$" > "$LOCK_PID" 2>/dev/null || true
return 0
fi
holder=$(cat "$LOCK_PID" 2>/dev/null || echo "")
if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then
log "stale lock (pid ${holder} dead) — 회수"
rm -f "$LOCK_PID" 2>/dev/null; rmdir "$LOCK_DIR" 2>/dev/null
continue # 즉시 재시도 (i 증가 없음)
fi
i=$((i+1)); sleep 1
done
return 1
}
mkdir -p "$RUN_STATE_DIR" 2>/dev/null || true
if ! acquire_lock; then
log "다른 apply 실행 중 — 락 획득 실패. 멱등하므로 종료(0)."
exit 0
fi
cleanup() { rm -rf "$LOCK_DIR" 2>/dev/null || true; }
trap cleanup EXIT INT TERM
mkdir -p "$SHADOW_DIR" 2>/dev/null || true
log "START (boot=${MODE_BOOT} country_now=${COUNTRY_NOW})"
seed_defaults_if_empty
# ──────── 1) /run 채우기 (양 모드) — tmpfs 는 부팅마다 비므로 항상 동기화 ────────
NETWORKD_DIRTY=0
CHANGED_IFACES=""
install -d "$RUNTIME_DIR" 2>/dev/null || true
for stale in 10-wlan0.network 10-eth0.network 10-eth1.network; do # 펌웨어식 잔여 이름 제거
[ -e "${RUNTIME_DIR}/${stale}" ] && { run rm -f "${RUNTIME_DIR}/${stale}"; NETWORKD_DIRTY=1; }
done
for r in $NET_RENDERS; do
src="${NETWORK_DIR}/${r}"; tgt=$(runtime_target "$r"); ifc=$(iface_of "$r")
if [ -f "$src" ]; then
if [ ! -f "$tgt" ] || ! cmp -s "$src" "$tgt"; then
if run install -m 0644 "$src" "$tgt"; then
NETWORKD_DIRTY=1; CHANGED_IFACES="${CHANGED_IFACES} ${ifc}"
log " runtime updated: ${tgt}"
fi
fi
elif [ -e "$tgt" ]; then
run rm -f "$tgt"; NETWORKD_DIRTY=1; CHANGED_IFACES="${CHANGED_IFACES} ${ifc}"
log " runtime removed (source gone): ${tgt}"
fi
done
# ──────── 2) wpa 변경 감지 (shadow 기준 — /run 사본이 없는 유일 파일) P1 ────────
WPA_CHANGED=0
if [ -f "$WPA_RENDER" ]; then
if [ ! -f "${SHADOW_DIR}/wpa_supplicant-wlan0.conf" ] || \
! cmp -s "$WPA_RENDER" "${SHADOW_DIR}/wpa_supplicant-wlan0.conf"; then
WPA_CHANGED=1
log " wpa render changed (SSID/PW/security/country header)"
fi
fi
# ──────── 3) country: modprobe.d 동기화 + 라디오 실효 기준 reboot 판정 (P2 / 오류①② 修) ────────
# 오류①: wlan 모듈은 부팅 초기(modules-load)에 로드되고 seed 는 그 뒤에 modprobe.d 를 갱신 →
# country 변경이 그 부팅엔 반영 안 됨(라디오는 옛값). 다음 부팅에야 적용.
# 오류②: 적용 검증을 wpa conf 값(wpa_cli)으로 하면 거짓 양성 → 반드시 라디오 실효값(live_country)으로.
DC=$(desired_country)
if [ -n "$DC" ] && valid_cc "$DC"; then
CUR=$(modprobe_country)
# (a) modprobe.d 를 목표로 동기화 (다를 때만). 런타임 modprobe -r 없음(deferred).
if [ "$DC" != "$CUR" ]; then
rootfs_free=$(df -k / 2>/dev/null | awk 'END{print $4}'); rootfs_free=${rootfs_free:-0}
if [ "$rootfs_free" -lt "$ROOTFS_MIN_KB" ]; then
log " country: rootfs 여유 ${rootfs_free}KB < ${ROOTFS_MIN_KB}KB — modprobe.d 쓰기 보류 (P6)"
elif printf 'options wlan country_code=%s\n' "$DC" > "${MODPROBE_CONF}.tmp.$$" 2>/dev/null \
&& mv -f "${MODPROBE_CONF}.tmp.$$" "$MODPROBE_CONF" 2>/dev/null; then
log " country: modprobe.d ${CUR:-none} -> ${DC}"
else
rm -f "${MODPROBE_CONF}.tmp.$$" 2>/dev/null || true
log " country: modprobe.d 쓰기 실패 (disk?) — 변경 보류"
fi
fi
# (b) reboot-required 판정은 "라디오 실효 country" 기준 (모듈이 실제 무엇으로 떠있나).
# modprobe.d 가 목표와 같아도 라디오가 다르면(=아직 그 값으로 모듈이 안 올라옴) 재부팅 필요.
LC=$(live_country)
if [ -n "$LC" ] && [ "$LC" = "$DC" ]; then
[ -f "$REBOOT_MARK" ] && { rm -f "$REBOOT_MARK" 2>/dev/null; log " country: 라디오 실효=${LC} == 목표 — reboot-required 해제"; }
else
: > "$REBOOT_MARK" 2>/dev/null || true
log " country: 라디오 실효=${LC:-unknown} != 목표 ${DC} → 재부팅 필요 (reboot-required)"
if [ "$MODE_BOOT" -eq 0 ] && [ "$COUNTRY_NOW" -eq 1 ]; then
note_country_now_deferred
fi
fi
elif [ -n "$DC" ]; then
log " country: 잘못된 코드 '${DC}' 무시"
fi
# ──────── 4) 라이브 적용 + 라이브-vs-의도 reconcile (P3 + L1) ────────
# on-demand: 항상 수행.
# boot: 정상 순서면 networkd 미기동 → 자연 적용에 맡기고 생략. 단 boot-ordering race 로
# networkd 가 이미 active 면(우리 /run 설치 전에 링크 구성) 라이브가 옛 값으로 굳으므로 reconcile.
DO_LIVE=0
if [ "$MODE_BOOT" -eq 0 ]; then
DO_LIVE=1
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
DO_LIVE=1
log " boot race 감지: networkd 가 이미 active → 라이브 reconcile 수행"
fi
if [ "$DO_LIVE" -eq 1 ]; then
# L1: 라이브-vs-렌더 정합성 — /run 이 렌더와 같아도 "라이브 주소 != 렌더 주소"면 reconfigure
# 강제. 펌웨어 reconcile_live_networkd_state 복원 (이게 빠져서 cold-boot race 후 옛 IP 가
# 그대로 남았음). /run-vs-렌더 비교만으로는 못 잡는 케이스를 닫는다.
RECON=""
for r in $NET_RENDERS; do
src="${NETWORK_DIR}/${r}"; ifc=$(iface_of "$r")
[ -f "$src" ] || continue
want=$(render_addr "$src"); [ -n "$want" ] || continue # DHCP — 비교 생략
have=$(live_addr "$ifc")
if [ "$want" != "$have" ]; then
RECON="${RECON} ${ifc}"
log " reconcile: ${ifc} live=${have:-none} != desired=${want} → reconfigure"
fi
done
# reconfigure 대상 = /run 변경 iface ∪ 라이브 불일치 iface
TARGETS=$(printf '%s\n' $CHANGED_IFACES $RECON | grep -v '^$' | sort -u)
if [ -n "$TARGETS" ]; then
if systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
run networkctl reload # P3
for ifc in $TARGETS; do
if command -v ip >/dev/null 2>&1 && ! ip link show dev "$ifc" >/dev/null 2>&1; then
log " ${ifc} 미존재 — reconfigure 생략"; continue
fi
run networkctl reconfigure "$ifc"
done
else
log " systemd-networkd 비활성 — reload/reconfigure 생략"
fi
fi
if [ "$WPA_CHANGED" -eq 1 ]; then # P1
if command -v wpa_cli >/dev/null 2>&1 && wpa_cli -i wlan0 ping >/dev/null 2>&1; then
run wpa_cli -i wlan0 reconfigure
else
run systemctl try-restart wpa_supplicant@wlan0.service
fi
fi
# ── 4.5) 검증 요약 (관측만 — 롤백 안 함). 재테스트 판정용. ──
if [ -n "$TARGETS" ] || [ "$WPA_CHANGED" -eq 1 ]; then
sleep 2 # 주소 settle
for r in $NET_RENDERS; do
src="${NETWORK_DIR}/${r}"; ifc=$(iface_of "$r")
[ -f "$src" ] || continue
want=$(render_addr "$src")
[ -n "$want" ] || { log " verify ${ifc}: DHCP/no-addr"; continue; }
have=$(live_addr "$ifc")
if [ "$want" = "$have" ]; then
log " verify ${ifc}: OK (${have})"
else
log " verify ${ifc}: MISMATCH want=${want} live=${have:-none}"
fi
done
if [ "$WPA_CHANGED" -eq 1 ]; then
st=$(wpa_cli -i wlan0 status 2>/dev/null | sed -n 's/^wpa_state=//p')
ss=$(wpa_cli -i wlan0 status 2>/dev/null | sed -n 's/^ssid=//p')
log " verify wlan0: wpa_state=${st:-?} ssid=${ss:-?}"
fi
fi
fi
# ──────── 5) shadow 갱신 (적용 기준선) ────────
for f in $NET_RENDERS wpa_supplicant-wlan0.conf wifi-country-code network_config.json; do
[ -f "${NETWORK_DIR}/${f}" ] && cp -f "${NETWORK_DIR}/${f}" "${SHADOW_DIR}/${f}" 2>/dev/null || true
done
log "DONE (networkd_dirty=${NETWORKD_DIRTY} wpa_changed=${WPA_CHANGED}$([ -f "$REBOOT_MARK" ] && echo ' reboot_required=1'))"
exit 0