#!/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