# Network Apply Engine (v1.6.0) — 종합 가이드 / Comprehensive Guide 이 문서는 v1.6.0 에서 도입된 **Network Apply Engine**(`src/network/` Python 패키지)을 코드베이스 지식 0 인 엔지니어가 전체 기능과 흐름을 이해할 수 있도록 한 곳에 모은 종합 가이드다. 웹 configurator 에서 바꾼 네트워크 설정을 OS 에 안전하게 적용·검증·롤백하고, 운영 중에는 상시 watchdog 으로 네트워크 무결성을 자가복구하는 전 과정을 다룬다 — 동기와 설계 철학부터 아키텍처, 상태머신, 데이터 계약, HTTP API, 백업/롤백/포렌식, UI, 배포·운영, 그리고 실기기 수락 결과·보안 자세·한계까지를 단일 voice 로 통합했다. 모든 기술 서술은 실제 소스 파일을 근거로 하며 `file:line` 또는 함수명으로 참조를 단다. | 항목 | 값 | |---|---| | 도입 버전 | v1.6.0 | | 현재 상태 | **v1.11.10** 기준 main 출시 — 엔진은 shipped 라인의 일부다. v1.11.6 부터 country 경로의 live `modprobe -r` 가 완전히 제거되어 **reboot-deferred 단일화**(§3.3 참조, [firmware-boot-hardening.md](firmware-boot-hardening.md)). | | branch | `feature/v1.6-network-apply` (도입 당시) | | tag | `f9b3acc` (도입 당시) | | 대상 장비 | `.56` (192.168.55.56, telechips-tcc8030-main, 유선 eth1) | | 작성일 | 2026-06-14 (도입), 갱신 2026-06-20 | ## 목차 / Table of Contents - [At a Glance / 한눈에 보기](#at-a-glance--한눈에-보기) - [1. Overview · Motivation · Features / 개요·동기·기능](#1-overview--motivation--features--개요동기기능) - [2. Architecture & Module Map / 아키텍처·모듈 지도](#2-architecture--module-map--아키텍처모듈-지도) - [3. Apply Flow & State Machine / 적용 흐름·상태머신](#3-apply-flow--state-machine--적용-흐름상태머신) - [4. 22-Field Contract & Byte-Exact Rendering / 22항목 계약·byte-exact 렌더](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더) - [5. Watchdog & Self-Healing / 상시 감시·자가복구](#5-watchdog--self-healing--상시-감시자가복구) - [6. HTTP API Contract / HTTP API 계약](#6-http-api-contract--http-api-계약) - [7. Snapshot · Rollback · Journal · Forensics / 백업·롤백·저널·포렌식](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식) - [8. UI / 사용자 인터페이스](#8-ui--사용자-인터페이스) - [9. Deployment & Operations / 배포·운영](#9-deployment--operations--배포운영) - [10. Acceptance · Security · Known Limitations / 수락 결과·보안·한계·백로그](#10-acceptance--security--known-limitations--수락-결과보안한계백로그) - [관련 문서 / References](#관련-문서--references) ## At a Glance / 한눈에 보기 이 기능의 핵심 UX·안전 결정은 **Save(DB 만) 와 Apply(OS 반영) 의 분리**다. **Save** 는 변경을 SQLite `device_config` 에 기록만 하고(현행 동작 그대로 — dpworldapp 다음 재시작/재부팅 시 반영), **Apply** 는 그 DB 상태를 기준으로 dry-run diff 를 먼저 보여준 뒤 동의를 받아 상태머신을 통해 OS 에 즉시 반영·검증하고 실패 시 자동 롤백한다. eth1(웹·SSH 접속 경로) 변경은 장비 스스로 도달성을 증명할 수 없으므로 **운영자 confirm 이 곧 검증**이며, 90초 안에 새 IP 로 재접속해 확정하지 않으면 이전 IP 로 자동 롤백된다. 운영 중에는 30초 주기 watchdog 이 적용본(`network_config.json`)을 기준으로 무결성을 상시 보증한다. ```mermaid flowchart TD A["운영자: Wi-Fi / Ethernet / Server 설정 입력
Save → DB device_config 기록"] --> B["Network → Apply & Status
drift 배너: DB ≠ 적용본"] B --> C{"변경 미리보기
dry_run=true"} C -->|"diff + 경고 뱃지
(eth1 lockout / country reload / wifi 단절)"| D["적용 시작
apply_async → apply_id (STARTED)"] D --> E["VALIDATING → SNAPSHOT
(적용 전 전체 백업 + DB 필드)"] E --> F["WRITING (DB-first) → APPLYING
(networkctl reload/reconfigure)"] F --> G["VERIFYING
link/carrier/주소·라우트/wpa/ping"] G -->|"eth1 변경"| H{"CONFIRM_WAIT
90s monotonic TTL"} G -->|"eth1 무변경 & verify OK"| K["COMMITTED
LKG 포인터 갱신"] H -->|"새 IP 재접속 → confirm"| K H -->|"TTL 만료 (재접속 실패)"| R["ROLLING_BACK"] G -->|"verify fail & !force"| R R -->|"복원+재적용+재검증 OK"| RB["ROLLED_BACK
(이전 IP 복구)"] R -->|"재검증 fail → recover 유닛 → 실패"| FC["FAILED_CRITICAL
(포렌식 번들 수집)"] K -.->|"운영 중 상시"| WD["Watchdog 30s tick
적용본 기준 자가복구"] ``` 핵심 요약 4줄: - **분리**: Save 는 DB 만, Apply 는 OS 반영 — 잘못 저장된 값이 곧바로 라이브에 닿지 않는다. - **불변식**: dpworldapp 의 매-재시작 재적용을 막을 수 없으므로, 렌더를 **byte-exact** 로 만들어 `cmp -s` no-op 이 되게 한다(안정성 요건). - **lockout 방지**: eth1 변경은 90초 confirm-or-rollback(monotonic TTL), 크래시 시 startup recovery 가 미완료 apply 를 보수적으로 롤백. - **자가복구**: 30초 watchdog 이 적용본 기준으로 무결성을 감시하되, 히스테리시스·cooldown·시간당 한도·WARN-only ping 으로 churn 을 억제한다. ## 1. Overview · Motivation · Features / 개요·동기·기능 **Network Apply Engine** 은 v1.6.0 에서 도입된 `src/network/` Python 패키지로, 웹 configurator 에서 변경한 네트워크 설정(WiFi / eth0 / eth1 / WiFi country code / 서버 엔드포인트, 총 22항목)을 **OS 에 즉시 적용·검증하고 실패 시 자동 롤백**하며, 운영 중에는 **상시 watchdog 으로 네트워크 무결성을 자가복구**한다. dpworldapp(타 팀 바이너리)은 블랙박스로 두고 그 파일 계약(render contract)을 byte 단위로 그대로 재사용한다. 근거 문서: release design notes, implementation plan, CHANGELOG `[v1.6.0]`(2026-06-13). ### 1.1 왜 만들었나 — 동기 기존에는 web configurator(Java `app.jar` 와 Python 신규 양쪽 모두)가 네트워크 설정을 SQLite `board_config.device_config` 에 **저장만** 했다(spec §1). OS 반영(wpa_supplicant, systemd-networkd)은 **dpworldapp 이 자기 재시작 시점에만** 수행하므로, 웹에서 SSID/passkey/ethernet 을 바꿔도 즉시 반영되지 않았다. ``` Web → SQLite board_config.device_config ← 여기서 끊김 ↓ dpworldapp 재시작 시에만 (app_init) dpworldapp: DB ↔ /home/root/network/network_config.json 비교 후 렌더·적용 ``` 이 끊김은 두 가지 실제 문제를 낳았다. - **즉시 반영 불가**: 변경이 다음 재부팅/dpworldapp 재시작까지 OS 에 닿지 않음. - **실패 복구 수단 부재(실사고)**: `eth_ip`(eth1, 로컬 이더넷)를 잘못 바꾸면 웹 UI·SSH 접근 경로가 끊겨 **SSH 가 유실되는 lockout 사고**가 발생했다(CHANGELOG `[v1.6.0]` 배경). 적용 경로에 검증·롤백이 전혀 없었기 때문이다. 사용자가 제시한 목표는 3가지였다(spec §1): ① 설정이 실제로 OS 에 반영(Apply 시 즉시, 검증 포함), ② 운영 중 네트워크 무결성(드라이버가 내려간 채 방치되는 상황 방지 — 상시 감시·자가복구), ③ debug logging(문제 발생 시 사후 보완조치가 가능한 증거 수집). ### 1.2 무엇을 하나 — 핵심 동작과 A안 아키텍처 웹에서 바꾼 네트워크 22항목을 **dpworldapp 파일 계약을 byte-exact 로 재사용**해 OS 에 즉시 적용·검증·롤백하고, 30초 주기 watchdog 으로 무결성을 상시 보증한다(plan Goal). dpworldapp 을 수정하지 않는 대신, dpworldapp 이 쓰는 적용 경로(`/home/root/network/` 렌더 파일 4종 + `dpworld-network-apply.service`)를 그대로 쓰고, 부족한 **트리거·검증·롤백·로깅**만 Python 안전계층으로 보강하는 **A안 아키텍처**를 채택했다(spec §2, §4). 이 설계의 핵심 불변식은 **byte-exact 렌더**다(spec §4.1, 자세히는 [§4](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더)). dpworldapp 은 load/save 비대칭 버그(spec §3.3-4) 때문에 재시작할 때마다 **무조건** JSON+렌더를 다시 쓰고 apply.service 를 기동한다. 따라서 "재적용을 막는" 것은 불가능하고, 우리 렌더가 dpworldapp 렌더와 **1바이트도 다르지 않게** 만들어 그 재적용이 `cmp -s` 무변화로 판정되어 **no-op** 이 되게 하는 것만이 안정성을 보장한다. 즉 렌더러의 byte 정확성은 미관이 아니라 안정성 요건이며, `.56` 실기기 캡처 golden 대비 **ZERO mismatch** 로 검증됐다(CHANGELOG). 22항목은 단순 WiFi/이더넷 IP 뿐 아니라 **서버 엔드포인트 필드(`lte_server_*`, `opc_ua_server_*`, `modbus_server_*`)까지 포함**한다(spec §3.4). 이들은 UI 상 '서버 설정' 페이지 소속이지만 dpworldapp 의 필드 비교 집합에 들어가므로 drift 감지·Apply 범위에 함께 포함된다. ### 1.3 핵심 기능 목록 - **즉시 적용 / dry-run 미리보기** — `POST /api/network/apply` 의 `dry_run` 플래그로 변경 필드 diff + 경고 뱃지(eth1 lockout / country 드라이버 리로드 / wifi 단절 / 보류 country 동반적용)를 먼저 보여주고, 실제 적용은 비동기로 `apply_id` 를 반환(plan §1.6). - **DB-first 쓰기** — 항상 DB → JSON/렌더 순서로 쓴다. 역순이면 중간에 dpworldapp 이 재시작할 때 구 DB 값으로 파일을 되돌려 충돌하므로, DB-first 여야 동일 값으로 수렴한다(spec §4.1). DB 쓰기는 wipe 사고(v1.4.0.2) 재발 방지를 위한 partial-merge + SQLite `busy_timeout`(dpworldapp 의 event_history 상시 INSERT 와 WAL 공유) 사용. - **byte-exact 렌더** — `network_config.json` + `10-{wlan0,eth0,eth1}.network` + `wpa_supplicant-wlan0.conf` + `wifi-country-code` 를 dpworldapp 과 byte 동일하게 생성(spec §4.1 no-op 불변식, golden-file 테스트). - **사후 검증(verifier)** — link / carrier / 주소·라우트 일치 / `wpa_state=COMPLETED` / gateway ping 확인. carrier 없는 인터페이스는 "config staged" WARN 으로 통과(spec §5 VERIFYING, §5.3). - **자동 롤백 + LKG(last-known-good)** — 적용 전 `/home/root/network/*` 전체 + DB 네트워크 필드를 `/opt/config_backups/network//` 에 manifest(파일 해시)와 함께 스냅샷. 검증 실패 시 자동 롤백, 검증 성공 시에만 last-known-good 포인터 갱신(spec §5 SNAPSHOT/ROLLING_BACK). - **eth1 90s confirm-or-rollback** — eth1 은 웹·SSH 접근 경로라 장비 스스로 외부 도달성을 증명할 수 없으므로 **사용자 confirm 이 곧 검증**이다. 적용 후 `CONFIRM_WAIT`(TTL 90초, monotonic 기준) 진입 → 새 IP 로 재접속해 Confirm 하면 COMMITTED, TTL 만료 시 이전 IP 로 자동 롤백(spec §6.1, Cisco `commit confirmed` / MikroTik Safe Mode 패턴). - **country deferred / 즉시** — country 변경은 `modprobe -r wlan` 리로드를 동반(IRQ78 패닉 이력 경로)하므로 **기본 reboot-deferred**(렌더만 일관 작성, apply.service 미트리거 + "Reboot required" 뱃지). 전문가 옵션 `country_now=true` 면 즉시 적용 + 강화 검증(spec §6.2). - **크래시 복구** — apply 상태+apply_id 를 디스크에 persist(`apply_state.json`). server.py 시작 시 미완료 apply 발견 → 보수적으로 자동 롤백. `recover_on_startup()` 은 HTTP 바인드를 막지 않도록 데몬 스레드로 실행(spec §5, CHANGELOG 리뷰 #1/#3). - **상시 watchdog 자가복구** — in-process 데몬 스레드, 30초 주기. 판정 기준은 DB 가 아니라 **적용본 `network_config.json`**. wlan 모듈/wpa/주소·라우트/eth0·eth1 per-iface 체크 + 복구 사다리. 억제 규칙: 히스테리시스(2회 연속 실패 시에만 발동) / cooldown 5분 / 시간당 최대 4회 / apply·confirm 중 일시정지 / gateway ping 은 WARN-only(자세히는 [§5](#5-watchdog--self-healing--상시-감시자가복구)). - **kill-switch** — 운영자가 watchdog 을 끄거나 critical 상태를 재무장할 수 있는 `POST/GET /api/network/config`(watchdog 제어 + `reset_critical`). watchdog 튜너블은 web-configurator 소유 신규 DB 키 `net_config` 에 저장(spec §8, CHANGELOG 리뷰 #2/#6). - **JSONL 저널 + 포렌식** — `network_journal.jsonl` 에 1줄 1이벤트(ts/uptime/boot_id/apply_id/category/phase/action/result/duration_ms/detail), 5MB×3 로테이션, **psk/password 상시 마스킹**. apply 실패·롤백·CRITICAL 시 dmesg/journalctl/networkctl/ip 상태를 묶은 포렌식 번들(≤2MB×5) 자동 수집(spec §9, 자세히는 [§7](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식)). ### 1.4 한눈에 보는 동작 — Save / Apply 분리 모델 핵심 UX 결정은 **Save(DB 만)와 Apply(OS 반영)의 분리**다(spec §2). **Save** 는 변경을 DB(`device_config`)에 기록만 한다(현행 동작 그대로 — 이 변경은 dpworldapp 다음 재시작/재부팅 시 dpworldapp 이 적용). **Apply** 는 그 DB 상태를 기준으로 dry-run diff 미리보기를 보여주고, 동의를 받은 뒤 상태머신(`VALIDATING → SNAPSHOT → WRITING → APPLYING → VERIFYING → COMMITTED`, 실패 시 `ROLLING_BACK`)을 통해 OS 에 즉시 반영·검증·롤백한다. 이때 diff 기준선은 **DB vs DB 가 아니라 "적용본"(`network_config.json`) vs DB** 다 — Save 가 이미 DB 를 갱신했으므로 DB-vs-DB 로는 Save→Apply 흐름이 항상 NOOP 이 되기 때문이다(plan §1.2, [§3.2(a)](#3-apply-flow--state-machine--적용-흐름상태머신)). 미적용(Save-only) 변경은 UI drift 뱃지가 "미적용 변경은 다음 재부팅 시 적용됩니다"로 인지시킨다(spec §4.1, [§8.2.1](#8-ui--사용자-인터페이스)). ## 2. Architecture & Module Map / 아키텍처·모듈 지도 이 서브시스템은 `src/network/` 패키지에 응집되어 있다. 운영자가 WiFi·이더넷·LTE 등의 네트워크 설정을 바꾸면, DB(`device_config`)의 의도(intent)를 dpworldapp 가 읽는 디스크 파일로 안전하게 렌더·적용하고, 실패 시 스냅샷에서 자동 롤백하며, 상시 watchdog 가 적용본과 라이브 상태의 drift 를 자가복구한다. 핵심 설계 원칙은 **dpworldapp 바이너리를 블랙박스로 두고 파일 계약만 공유**하는 것이다(dpworldapp 를 한 줄도 고치지 않는다). `src/network/__init__.py` 는 빈 파일이며, 패키지 멤버는 절대 import(`from network import ...`)로 서로를 참조한다. ### 2.1 9개 모듈의 책임 (한 줄 요약) | 모듈 (`src/network/`) | 책임 (한 줄) | 근거 (docstring / 핵심 심볼) | |---|---|---| | `netmodel.py` | DB `device_config` ↔ intent ↔ persist JSON 매핑 + 비교(diff) 의미론. dpworldapp 의 필드 비교 동작(32-bit (u32) IP 비교) 정합. apply 가 쓸 수 있는 네트워크 키의 **단일 출처** `NETWORK_DEV_KEYS` 보유. | `netmodel.py:2-3`, `NETWORK_DEV_KEYS` `:11-16`, `intent_from_device` `:47` | | `renderer.py` | intent → dpworldapp **바이트 호환** 렌더 (systemd-networkd `[Network]/[Route]`, `wpa_supplicant.conf`, country 파일, `network_config.json` persist). | `renderer.py:2-3`, `render_network_file` `:16`, `render_wpa_conf` `:33`, `render_persist_json` `:57` | | `validator.py` | §5.2 hard rule — dpworldapp 파서/렌더 한계(버퍼 절단, escaping 부재, profile gap, 포트 범위 등)를 데이터로 위반하지 않도록 사전 거부. | `validator.py:2`, `validate(intent)` `:39` | | `journal.py` | JSONL 네트워크 이벤트 저널(1줄 1이벤트, 5MB×3 로테이션, psk/password 마스킹) + 실패/롤백 시 `forensic_bundle()` 증거 수집. `event()` 는 절대 raise 안 함. | `journal.py:2-3`, `class Journal` `:37`, `event()` `:44` | | `snapshot.py` | apply 직전 백업(`/home/root/network/*` 전체 + DB 네트워크 필드) + manifest SHA-256 해시 검증 + 복원. `last_known_good`(LKG) 포인터. | `snapshot.py:1-2`, `take()` `:22`, `verify()` `:54`, `LKG` `:5` | | `verifier.py` | 사후 검증(VERIFYING): 존재→carrier→주소·라우트→wpa_state→gateway ping. carrier 없음 = 'config staged' WARN. 모든 외부호출은 runner 주입. | `verifier.py:1-2`, `verify()` `:25`, `_ip_brief()` `:14` | | `apply_engine.py` | Apply 상태머신(VALIDATING→SNAPSHOT→WRITING→APPLYING→VERIFYING→CONFIRM_WAIT). 단일 in-flight, 기준선=적용본, DB-first 쓰기, confirm TTL 타이머, 크래시 복구. | `apply_engine.py:1-2`, `class ApplyEngine` `:31`, `apply_async()` `:252`, `recover_on_startup()` `:599` | | `watchdog.py` | 상시 감시·자가복구(틱 30s, 히스테리시스 2, cooldown 5분, 시간당 4회 한도). 판정 기준 = `network_config.json`(적용본). 복구 사다리(`_LADDERS`)로 단계적 escalation. | `watchdog.py:1-6`, `class NetworkWatchdog` `:43`, `start()` `:293`, `tick_once()`(loop `:301`) | | `net_routes.py` | HTTP 라우팅 글루 — 서버 독립적, dict in/out. `/api/network/*` 요청을 engine/watchdog 메서드로 위임. `RouteError(status, message)` 로 검증 실패 표현. | `net_routes.py:1`, `class NetworkRoutes` `:26`, `apply()` `:31`, `config()` `:99` | ### 2.2 모듈 의존 방향 — 단방향, 순환 없음 패키지 내부 의존은 **단방향**이며 순환이 없다. 상위(라우트/감시) → 하위(엔진) → 말단(렌더/검증/스냅샷/저널/모델)으로만 흐른다. ```mermaid flowchart LR NR["net_routes"] --> AE["apply_engine"] WD["watchdog"] -->|"억제 해제 시 위임"| AE AE --> R["renderer"] AE --> V["validator"] AE --> SN["snapshot"] AE -.->|"지연 import :38"| VF["verifier"] AE -->|"주입"| J["journal"] AE --> NM["netmodel"] WD --> VF WD --> J WD --> NM R --> NM V --> NM VF --> NM SN --> NM ``` 구체적 import 증거: - `net_routes.py:3` → `from network import netmodel`(write-allowlist `NETWORK_DEV_KEYS` 검사용, `apply()` `:38`). - `apply_engine.py:5` → `from network import netmodel, renderer, snapshot, validator`; verifier 는 순환·지연 회피를 위해 메서드 내부 `:38`에서 지연 import. - `watchdog.py` 는 생성자(`:44`)로 `engine`·`journal` 를 주입받고, 판정에서 netmodel/verifier 를 사용한다(외부 호출은 `runner` 주입 `:47`). - `verifier.py:5` → `from network import netmodel`(`effective_profiles` 로 wpa 검증 게이트 판단 `:57`). 핵심: `NETWORK_DEV_KEYS`(netmodel)가 **단일 출처(single source)** 다. `net_routes.apply()` 의 write-allowlist(`net_routes.py:38`)와 `apply_engine._db_network_fields()` 의 스냅샷 키(`apply_engine.py:120` `sorted(netmodel.NETWORK_DEV_KEYS)`)가 모두 이 집합에서 파생되어, "허용 키 ≠ 스냅샷 키" drift 를 구조적으로 차단한다. 이 집합은 20개 키다(`wifi_*` 7, `WIFI_SSID`, `eth_*` 3, `lte_*` 6, `opc_ua_server_*` 2, `modbus_server_*` 2 — `netmodel.py:11-16`). docstring 의 "22항목"은 spec §3.4 의 intent 수준 항목 집합을 가리키고, "20 네트워크 키"(`net_routes.py:36`)는 device_config 키 화이트리스트를 가리킨다(둘의 관계는 [§4.1](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더) 표 참조). ### 2.3 server.py 가 어떻게 엮는지 `server.py` 는 이 서브시스템의 **유일한 조립점**이며, 세 가지로 통합한다: (a) import-time 인스턴스 생성 + 503 fallback, (b) HTTP 라우트 위임, (c) 시작 시 복구·watchdog 기동·provider 주입. **(a) import-time 생성 + 503 fallback** — `server.py:148-174` ```python try: from network.apply_engine import ApplyEngine from network.journal import Journal as _NetJournal from network.net_routes import NetworkRoutes, RouteError as NetRouteError from network.watchdog import NetworkWatchdog _NET_JOURNAL = _NetJournal(.../"network_journal.jsonl") _NET_ENGINE = ApplyEngine(db=db, net_dir=NET_DIR, backups_dir=..., state_path=..., journal=_NET_JOURNAL, runner=_net_run) _NET_WATCHDOG = NetworkWatchdog(db=db, engine=_NET_ENGINE, journal=_NET_JOURNAL, net_dir=NET_DIR) _NET = NetworkRoutes(_NET_ENGINE, _NET_JOURNAL, _NET_WATCHDOG, _net_drift, _net_live) except Exception as _e: _NET_INIT_ERROR = str(_e) ... # NetRouteError = _RouteErrorStub ``` 경로 생성 등이 실패하는 환경(예: Windows dev box, `/home/root/network` 부재)에서도 **서버 import 자체는 절대 실패하지 않는다**(`server.py:166` 주석). 이때 `_NET`/`_NET_ENGINE`/`_NET_WATCHDOG` 는 `None` 으로 남고, 모든 `/api/network/*` 요청은 `_net_json()`(`server.py:682`)에서 **503** 으로 응답한다: ```python def _net_json(self, fn): if _NET is None: self._send_json_response({"ok": False, "error": f"network subsystem unavailable: {_NET_INIT_ERROR}"}, 503) return try: self._send_json_response(fn()) except NetRouteError as e: self._send_json_response({"ok": False, "error": e.message}, e.status) except Exception as e: ... 500 ``` 주입 콜백 두 개(`_net_drift` `server.py:126`, `_net_live` `server.py:141`)도 server.py 가 제공한다 — 라우트는 서버 독립적이므로 drift/live 계산은 외부에서 들어온다. runner(`_net_run` `server.py:117`)는 `subprocess.run` 래퍼로 engine 에 주입되어, 모든 OS 명령(`ip`, `networkctl`, `wpa_cli` 등)이 한 곳을 통과한다(테스트 시 교체 가능). **(b) HTTP 라우트 위임** — engine/watchdog 을 직접 부르지 않고 전부 `_NET`(NetworkRoutes)로 위임한다(엔드포인트별 계약은 [§6](#6-http-api-contract--http-api-계약)). | HTTP | 위임 | 위치 | |---|---|---| | `GET /api/network/state` | `_NET.state()` | `server.py:445-446` | | `GET /api/network/apply/status?id=` | `_NET.status(aid)` | `:447-451` | | `GET /api/network/journal?limit=` | `_NET.journal_tail(lim)` | `:452-456` | | `GET /api/network/config` | `_NET.get_config()` | `:457-459` | | `POST /api/network/apply` | `_NET.apply(data)` | `:595-604` (non-dict body → 400) | | `POST /api/network/apply/confirm` | `_NET.confirm(data)` | `:605-614` | | `POST /api/network/rollback` | `_NET.rollback()` | `:615-616` | | `POST /api/network/config` | `_NET.config(data)` | `:617-626` (watchdog kill-switch) | `POST /api/network/apply` 는 비동기다 — `net_routes.apply()`(`net_routes.py:46`)가 `engine.apply_async()` 를 호출해 `STARTED` + `apply_id` 를 즉시 반환하고, 검증·적용 결과는 status 폴링으로 가져온다(eth1 IP 변경 후엔 구 연결로 동기 응답이 불가하기 때문 — `net_routes.py:44`). `apply()` 는 또한 dry_run 분기와 `NETWORK_DEV_KEYS` 화이트리스트 검사(unknown 키 → 400)를 수행해 `/setting/device` 검증을 우회하는 side-door 를 막는다(`net_routes.py:35-40`). **(c) 시작 시 복구 → watchdog 기동 → provider 주입** — `server.py:1099-1109`(main 의 `run()`) ```python if _NET_ENGINE is not None: if _NET_ENGINE.recover_on_startup(): # 1. 미완료 apply 복구 / country_pending 해소 print("[startup] network apply recovery: rolled back unfinished apply", ...) if _NET_WATCHDOG is not None: _NET_WATCHDOG.start() # 2. 상시 감시 데몬 스레드 기동 if _NET is not None: set_network_apply_provider(_network_apply_provider) # 3. system_status 에 요약 provider 주입 ``` - (1) `recover_on_startup()`(`apply_engine.py:599`)은 크래시 후 `apply_state.json` 을 읽어 미완료 apply 를 복구한다. 빠른 판정(파일 read)만 인라인, 느린 롤백(subprocess 수십 초)은 데몬 스레드로 띄워 HTTP bind 를 막지 않는다(운영자 lockout 방지 `:602-606`). 자세히는 [§3.6](#3-apply-flow--state-machine--적용-흐름상태머신)·[§7.2(e)](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식). - (2) `watchdog.start()`(`watchdog.py:293`)는 `loop()` 데몬 스레드를 띄워 `tick_once()` 를 interval 마다 호출한다. watchdog 은 절대 죽지 않도록 모든 예외를 삼킨다(`watchdog.py:303-304`, [§5](#5-watchdog--self-healing--상시-감시자가복구)). - (3) `set_network_apply_provider()`(`system_status.py:1032`)로 `_network_apply_provider`(`server.py:177`)를 system_status 에 주입한다. **import 순환을 피하기 위해** system_status 는 provider 함수 포인터만 보유하고(`system_status.py:1024`, `:1028-1034`), server.py 가 main 직전에 setter 로 꽂는다. provider 는 watchdog `snapshot()` 1회 + drift + country_pending + iface 상태를 `system_status` 의 `network_apply` 키로 합쳐 노출하며, 실패해도 `{"error": ...}` 로 fail-soft 한다(`server.py:189-190`, UI 표시는 [§8.4](#8-ui--사용자-인터페이스)). 추가로 `support_bundle.py:87-96` 는 진단 번들 생성 시 `network_journal.jsonl` tail 200줄을 포함하되, 부재 시 `"[]"` 로 fail-soft 한다(서브시스템이 없어도 번들 생성은 성공). ### 2.4 dpworldapp 와의 경계 — 블랙박스, 파일 계약만 공유 dpworldapp 는 `/usr/bin/dpworldapp` 바이너리(CAN/Modbus/OPC-UA 데이터 처리)로, 이 서브시스템은 그것을 **수정하지 않으며 직접 호출하지도 않는다**. 경계는 전적으로 **공유 파일 계약**이다: - **렌더 정합(출력 측)**: `renderer.py` 가 만드는 systemd-networkd 파일·`wpa_supplicant.conf`·country 파일·`network_config.json` 은 dpworldapp 가 생성/소비하는 캡처(golden)와 **1바이트라도 다르면 안 된다**(`renderer.py:2-3`). 예: gateway u32==0 일 때 `[Route]` 생략은 deployed dpworldapp binary 동작과 동일하게 맞춘 것(`renderer.py:23`). - **비교 정합(의미 측)**: diff 비교는 dpworldapp 의 필드 비교(32-bit (u32) IP 비교) 의미론을 미러링한다(`netmodel.py:3`, `_ip_u32` `:25-41`). 빈 문자열·`0.0.0.0` 은 zero-동치로 취급(§1.2). dpworldapp 가 dhcp 모드 필드를 파싱하지 않으므로, dhcp 시 DB 잔존값은 무시하고 zero-out 한다(`netmodel.py:59`). - **파서 한계 존중(검증 측)**: `validator.py` 가 dpworldapp 의 버퍼 절단(SSID 19/20byte, PSK 8-19byte `validator.py:6-7`)·escaping 부재(`"`/`\` 금지 `:9`)·profile gap(빈 슬롯 뒤 항목은 침묵 탈락 `:46-52`)을 데이터 차원에서 사전 거부한다(전체 규칙은 [§4.5](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더)). - **포트/타입 정합**: `render_persist_json` 의 `_port_num`(`renderer.py:51-55`)은 dpworldapp cJSON 이 port 를 JSON number 로 기록한다는 캡처에 맞춰 String DB 값을 캐스팅한다. - **빌드 호환 검증(watchdog)**: `watchdog.py:14` `COMPAT_DPWORLDAPP_MD5` 가 렌더 계약이 검증된 dpworldapp 빌드의 md5 를 들고, `start()` 시 `_check_fw_md5()`(`watchdog.py:297`)로 라이브 바이너리와 대조해 계약 drift 를 경고한다. - **DB·파일 경계**: 설정 DB(`board_config`/`device_config`)와 적용본 파일(`/home/root/network/*`)이 양측 공유 매체다. watchdog 의 판정 기준선은 DB 가 아니라 **적용본 `network_config.json`**(`watchdog.py:1`, `apply_engine._baseline_intent()` `:88-90`) — Save 가 이미 DB 를 갱신하므로 DB-vs-DB 비교는 Save→Apply 를 항상 NOOP 으로 만들기 때문이다. 정리하면, 이 서브시스템은 dpworldapp 의 **입력 파일을 정확히 그 형식대로 써 주고, dpworldapp 가 보는 그대로 라이브 상태를 검증·복구**할 뿐, dpworldapp 바이너리·프로세스에는 손대지 않는다(파일 수정 0, 코드 수정 0). ## 3. Apply Flow & State Machine / 적용 흐름·상태머신 네트워크 설정 변경을 라이브 시스템에 안전하게 반영하는 핵심 엔진이다. 단순히 파일을 쓰고 `networkctl reload` 를 부르는 수준이 아니라, **검증 → 스냅샷 → 쓰기 → 적용 → 검증 → (eth1) 확인대기 → 커밋** 의 다단계 상태머신으로 구성되며, 어느 단계에서 실패하거나 프로세스가 죽어도 라이브 네트워크를 깨뜨리지 않도록 롤백·크래시 복구 경로를 갖췄다. 구현은 전부 `src/network/apply_engine.py` 의 `ApplyEngine` 클래스이고, HTTP 진입점은 `src/network/net_routes.py` 의 `NetworkRoutes.apply()` 이다([§6](#6-http-api-contract--http-api-계약)). 설계의 위험 핵심은 단 하나다: **운영자가 eth1(원격 관리 인터페이스)의 IP 를 바꾸면, 적용 직후 운영자 자신의 연결이 끊어진다.** 잘못된 설정이면 영영 못 돌아온다. 그래서 이 엔진의 거의 모든 불변(invariant)은 "운영자 lockout 방지"를 향한다. ### 3.1 전체 라이프사이클 (Lifecycle) #### 진입 — HTTP `apply()` (`net_routes.py:31`) `NetworkRoutes.apply(body)` 가 단일 진입점이다. 흐름: 1. `body["fields"]` 가 dict 인지 검사 (아니면 `RouteError(400)`). 2. **화이트리스트 검사**(`net_routes.py:38`): `fields` 의 모든 키가 `netmodel.NETWORK_DEV_KEYS` 에 속해야 한다. 임의 `device_config` 키가 apply 를 통해 들어오면 스냅샷이 그 키를 포착하지 못해 롤백으로도 제거할 수 없으므로(side-door), 미지 키는 `RouteError(400)` 로 즉시 거절. 3. `dry_run` 분기(`_req_bool` 으로 엄격 bool 파싱): true 면 `engine.dry_run(...)` 결과만 반환 — 라이브 무접촉. 4. 실제 적용이면 `engine.apply_async(...)` 호출. 반환이 `BUSY` 면 `RouteError(409)`, 아니면 `{"ok": True, "state": "STARTED", "apply_id": ...}` 즉시 반환. `force` / `country_now` 플래그는 `_req_bool()`(`net_routes.py:11`)로 파싱한다. JSON 문자열 `"false"` 가 Python truthiness 로 True 가 되어 위험 플래그가 잘못 켜지는 것을 막기 위해, **진짜 `bool` 만 수용하고 None=미지정(False), 그 외 타입은 400** 으로 거절한다([§6 입력 검증 가드](#6-http-api-contract--http-api-계약)). #### dry-run (`apply_engine.py:206`) `dry_run(new_dev_fields, country_now)` 은 부작용 없이 미리보기를 만든다: - 기준선 intent(`_baseline_intent()`)와 `{DB ∪ new_fields}` 로 만든 새 intent 를 `diff_intents` 로 비교 → `diff`. - `validator.validate(new)` 로 에러/경고 수집. - diff 가 있으면 **실 적용과 동일한 country 게이트**(`_country_gate_errors(..., fast=True)`)를 미리 돌려 에러를 노출 — dry-run 은 OK 인데 실제 apply 는 거절되는 divergence 를 막는다. dry-run 은 동기 HTTP 경로이므로 `fast=True` 로 hung `wpa_cli` 가 응답을 ~19s 블록하지 못하게 한다. - 경고로 `eth1_confirm`, `wifi_disrupt`, `country_reboot_deferred`, `dns_saved_only`(dns1/dns2 는 현 펌웨어 미적용 dead field), `country_deferred_pending` 등을 부착. #### apply_async — 즉시 반환 + 데몬 스레드 (`apply_engine.py:252`) ```python def apply_async(self, new_dev_fields, force=False, country_now=False): pre = self._begin(force, country_now) if pre["state"] == "BUSY": return pre self._thread = threading.Thread(target=self._run_protected, args=(new_dev_fields,), daemon=True, name="net-apply") self._thread.start() return pre # {"state": "STARTED", "apply_id": ...} ``` **왜 비동기인가**: eth1 IP 가 바뀌면 `networkctl reconfigure eth1` 이후에는 구 연결로 HTTP 응답을 보낼 수 없다. 따라서 HTTP 핸들러는 `apply_id` 를 즉시(`STARTED`) 반환하고 본체는 데몬 스레드에서 돌린다. 운영자는 (구 IP 가 살아있는 동안) `status(apply_id)` 폴링으로 진행을 추적한다. `_begin()`(`apply_engine.py:227`)이 in-flight 잠금과 상태 초기화를 담당한다: - `_cur["state"]` 가 `_ACTIVE`(VALIDATING/SNAPSHOT/WRITING/APPLYING/VERIFYING/ROLLING_BACK) 또는 `CONFIRM_WAIT` 면 → `BUSY` 반환. **단일 in-flight** 보장. - 직전 persist 의 `country_pending` 을 `_read_persisted_pending()` 으로 읽어 `_prior_country_pending` 에 보관 (이후 NOOP/검증실패 persist 가 보류 플래그를 wipe 못 하게). - `apply_id` = `ap-{날짜시각}-{monotonic%1000}-{seq}` 생성, `_cur` 를 `VALIDATING` 으로 초기화 후 persist. #### 본체 — `_run_machine()` 의 단계 순서 (`apply_engine.py:290`) | 단계 | 하는 일 | 핵심 | |------|---------|------| | **VALIDATING** | `diff = diff_intents(적용본, DB∪new)` → 빈 diff 면 `NOOP`. `validator.validate` + country 게이트. 에러 시 `FAILED_VALIDATION`. | 게이트는 라이브 실효 변경 기준 | | **SNAPSHOT** | `snapshot.take(net_dir, backups_dir, aid, db_fields)` 로 현재 렌더 파일 + DB 네트워크 필드 백업. `snapshot_dir` 저장. | 롤백 기준점. **이 시점 전까지는 라이브 무접촉** | | **WRITING** | DB-first: `_write_db(new_fields)`(partial-merge) → `_write_renders(new_it)`(systemd-networkd / wpa conf 등). | DB 가 먼저, 그 다음 파일 | | **APPLYING** | `systemctl start dpworld-network-apply.service`(best-effort) → `networkctl reload` → 변경된 iface 별 `networkctl reconfigure` → wpa 변경 시 `wpa_cli reconfigure`(+ fallback `try-restart`). | apply.service 실패는 WARN, networkctl 이 실 적용 | | **VERIFYING** | `verify_fn(new_it, changed_ifaces)`. fail 이고 `force` 아니면 → 롤백. force 면 WARN 후 강행. | verify 가 실 게이트 | | **CONFIRM_WAIT** | eth1 이 변경 ifaces 에 있으면만. 90s TTL 설정 + 타이머 기동. | 운영자 재접속 confirm 대기 | | **COMMITTED** | `_commit()` — 타이머 취소, LKG 마킹/prune(fail-soft). | 최종 성공 | country-only 변경이면서 `country_now` 미동의 시 WRITING 직후 **deferred** 분기로 빠진다: APPLYING 을 건너뛰고 바로 `COMMITTED` + `country_pending=True`(재부팅 필요). 단, **diff 가 정확히 country 단독일 때만**(`len(diff)==1 and diff[0]["field"]=="wlan0.country_code"`, `apply_engine.py:339`) — 다른 필드가 이 분기로 새어 "COMMITTED 인데 미적용" 되는 lockout hole 을 막는다. #### 실패·종료 경로 ```text VERIFY fail / 예외(스냅샷 후) → ROLLING_BACK → 스냅샷 복원(DB-first) + 재적용 + 재검증 ├ 재검증 OK → ROLLED_BACK └ 재검증 fail → recover 유닛 1회 → 안 되면 FAILED_CRITICAL NOOP / FAILED_VALIDATION / ABORTED(스냅샷 전 예외) — 라이브 무접촉 종료 ``` `_run_protected()`(`apply_engine.py:262`)가 모든 예외를 감싼다: - **스냅샷 전 예외**(`snapshot_dir is None`): 라이브 무접촉이므로 거짓 `FAILED_CRITICAL`/recover 유닛을 띄우지 않고 `ABORTED` 로만 마킹(`apply_engine.py:268`). - **스냅샷 후 예외**: `_claim(_ACTIVE, "ROLLING_BACK")` 선점 후 `_rollback`. - `finally`(`apply_engine.py:280`): 워커가 active state 인 채로 죽으면 무조건 `FAILED_CRITICAL` 로 강등 — **워커는 절대 active state 로 죽지 않는다** 는 불변(I9). ### 3.2 핵심 불변 / 설계 (Invariants) #### (a) diff 기준선 = 적용본(persist), DB 가 아니다 — `_baseline_intent()` (`apply_engine.py:88`) ```python def _baseline_intent(self): """diff 기준선 = 적용본 network_config.json (§1.2).""" try: with open(os.path.join(self.net_dir, "network_config.json"), ...) as f: return renderer.intent_from_persist(json.load(f)) except (OSError, ValueError): return netmodel.intent_from_device({}) # 부재/손상 → 전부-변경 취급 ``` diff 를 "DB vs DB" 로 잡으면 안 된다. **Save 가 이미 DB 를 갱신**하므로, Save 직후 Apply 를 누르면 DB==DB 라 항상 NOOP 이 되어 영영 적용이 안 된다. 그래서 기준선은 "마지막으로 실제 적용된 결과"인 `network_config.json`(persist/적용본)이고, 비교 대상이 현재 DB(=user-saved)다. 적용본 파일이 없거나 손상이면 빈 intent → "전부 변경" 으로 취급해 첫 적용이 가능하게 한다(fresh-flash, [§7.1(e)](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식)). #### (b) DB-first 쓰기 (`_write_db` 먼저, `_write_renders` 나중) WRITING 단계와 롤백 복원 양쪽 모두 **DB → 파일 순서**. DB 가 진실의 원천이고, 렌더 파일은 DB 로부터 파생된 산출물이라는 일관성을 지킨다. 롤백의 `_write_db_restore(present, absent)`(`apply_engine.py:132`)는 스냅샷의 present 키 복원 + 스냅샷 시점에 없던 키 pop 까지 하여, merge 복원이 "없던 키를 부활"시키는 결함을 막는다(스냅샷 포맷 `fmt:2`, `apply_engine.py:114`). #### (c) Monotonic TTL — RTC 불신뢰 confirm 마감은 `confirm_deadline_monotonic = self.clock() + CONFIRM_TTL_S`(기본 `clock=time.monotonic`, `apply_engine.py:36`). 임베디드 보드의 RTC(벽시계)는 NTP 동기 전이나 배터리 방전 시 점프할 수 있어, wall-clock 기반 TTL 은 즉시 만료되거나 영영 안 만료될 수 있다. monotonic 은 단조 증가만 하므로 "90초 경과" 판정에 신뢰 가능하다. #### (d) 단일 in-flight + CAS 전이 `_begin()` 의 BUSY 체크가 동시 apply 를 막고, `_claim(from_states, to_state)`(`apply_engine.py:75`)가 `_lock` 하에서 현재 state 가 예상과 일치할 때만 전이하는 CAS(compare-and-swap)다. confirm 과 TTL 타이머가 같은 `CONFIRM_WAIT` 를 동시에 소비하려는 race 의 단일 결정점이다 — 승자만 롤백/커밋 본문을 실행한다. `_rollback` 진입부엔 멱등 가드(이미 terminal 이면 재진입 금지, `apply_engine.py:428`)도 있다. #### (e) 롤백 재검증 기준 = 복원된 persist 롤백 후 재검증의 기준 intent 는 **복원된 `network_config.json`** 이다(`apply_engine.py:478`). Save-then-Apply drift flow 에선 DB 가 apply 전에 이미 NEW 값으로 바뀌어 스냅샷 db_fields 도 NEW 다. 그 NEW 기준으로 재검증하면 복원된 라이브(OLD)와 영원히 불일치해 가짜 `FAILED_CRITICAL` 이 난다. 그래서 기준을 복원 persist 로 잡고, persist 판독 불가일 때만 `intent_from_device(present)` 로 폴백한다([§7.2(b)](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식)). ### 3.3 country 게이팅 (`_country_gate_errors`, `apply_engine.py:185`) WiFi regulatory country 변경은 wlan 커널 모듈의 regdomain 파라미터 변경을 동반하므로 특별 취급한다. 게이팅 기준은 "라이브 실효 변경" — 라디오 실효 regdomain(`_live_country`, `apply_engine.py:160`; v1.9.1 부터 `iw reg`/sysfs 기준, `wpa_cli get country` 아님 — wpa conf 렌더값은 라디오 실효값이 아니라 거짓 양성을 내므로)과 target 을 비교하고, live 미상이면 diff 기준으로 보수 폴백한다. > **v1.11.6 변경 — country 는 항상 reboot-deferred (live `modprobe -r` 제거)** > QCA6490(cnss_pci) 에서 런타임 wlan 모듈 unload 가 hang → Telechips PMU > 하드웨어 watchdog 리셋 → 부팅 루프를 유발한 이력 때문에, v1.11.6 부터는 **live > `modprobe -r` 경로를 완전히 제거**했다. apply.service 는 drop-in > (`dpworld-network-apply.service.d/20-hardened.conf`)으로 하드닝 스크립트 > (`deploy/dpworld-network-apply-hardened.sh`)를 실행하는데, 이 스크립트는 country > 를 `/etc/modprobe.d` 에 기록하고 `reboot-required` marker 만 세울 뿐 모듈을 > 내리지 않는다. 따라서 전문가 옵션 `country_now=true` 라도 **같은 부팅에서는 > 라디오 실효 country 가 바뀌지 않으며**, 실제 적용은 재부팅 시점이다. 상세는 > [firmware-boot-hardening.md](firmware-boot-hardening.md) 참조. | 상황 | 동작 | |------|------| | **deferred (기본)** | country-only 변경 + `country_now` 미동의 → 파일/DB 만 쓰고 apply.service 미기동, `COMMITTED` + `country_pending=True`. 재부팅 시 실제 반영. | | **country_now (전문가 옵션)** | 동의 시 apply.service(=하드닝 스크립트)가 `modprobe.d` 를 갱신하고 `reboot-required` marker 를 세운다 — **live 모듈 리로드 없음**(v1.11.6). 엔진은 이후 강화 검증 사다리(`/sys/module/wlan` → `/sys/class/net/wlan0` → `_live_country == target`)를 돌리는데, 모듈을 내리지 않았으므로 라디오 실효 country 는 아직 OLD 라 보통 검증 불일치 → 롤백(`apply_engine.py:413-427`). 실효 변경은 **재부팅 후**에야 일어난다. | | **혼합 (v1.9 split-apply)** | country 실효 변경 + 다른 필드 동반(`len(diff)>1`) → 비-country 필드는 즉시 적용, country 는 deferred 로 분리(split-apply). | | **보류 중 동결** | 직전 country 가 재부팅 대기(pending)인데 추가 apply 시도 + `country_now` 미동의 → 거부(deferral 분기로 새는 silent commit 차단). | > **이력 참고:** 위 표의 `country_now` 행은 코드 경로(엔진이 여전히 강화 검증 > 사다리를 돈다)를 그대로 기술한 것이다. v1.11.6 이전 빌드는 이 경로에서 모듈을 > 실제로 리로드해 재부팅 없이 즉시 반영했으나, 현재는 하드닝 스크립트가 리로드를 > 거부하므로 실효 변경이 재부팅으로 미뤄진다. ### 3.4 force 모드 (검증 생략, 저널 기록) `force=True` 면 VERIFYING 에서 verify fail 이 나도 롤백하지 않고 강행한다(`apply_engine.py:386`). 단 silent 가 아니라 저널에 `force_commit` WARN 을 남긴다. VALIDATING 단계의 step detail 에도 `[force]` 를 명시한다(`apply_engine.py:322`). 운영자가 verify 로직이 못 따라가는 특수 토폴로지를 강제 적용할 탈출구이되, 흔적은 반드시 남긴다. (country 게이트 자체는 force 로 우회되지 않는다 — VALIDATING 의 `errs` 검사는 force 무관하게 거절.) ### 3.5 eth1 confirm 흐름 (`apply_engine.py:391`, `confirm`/`tick`) eth1 이 바뀌면 VERIFYING 통과 후 즉시 COMMITTED 하지 않고 `CONFIRM_WAIT` 로 들어간다: 1. `confirm_deadline_monotonic = now + 90s` 설정, persist. 2. `timer_factory(91s, self.tick)` 데몬 타이머 기동(1차 만료 보장). 3. 운영자는 **새 IP 로 재접속** 한 뒤 `confirm(apply_id)`(`net_routes.py:56` → `apply_engine.py:535`) 호출 → 매칭되는 CONFIRM_WAIT 이면 `_commit()`. 4. 90초 내 confirm 이 없으면(= 새 설정이 깨져 재접속 실패) `tick()`(`apply_engine.py:542`)이 마감 경과를 감지하고 `_claim(("CONFIRM_WAIT",), "ROLLING_BACK")` 승리 시 **자동 롤백** → 구 IP 가 복구되어 운영자가 다시 들어올 수 있다. `tick()` 은 세 경로에서 호출된다: 엔진 자체 타이머(1차), watchdog, 그리고 status/state 폴링(`net_routes.py:53,72` — 폴링이 안전망 타이머 역할). confirm 과 tick 의 race 는 `_claim` CAS 가 단일 소비를 보장한다. `is_busy_or_confirming()`(`apply_engine.py:558`)은 watchdog 의 복구 사다리가 CONFIRM_WAIT 중인 eth1 확인 대기를 침범하지 못하게 억제하는 데 쓴다([§5.4](#5-watchdog--self-healing--상시-감시자가복구)). ### 3.6 크래시 복구 (`recover_on_startup`, `apply_engine.py:599`) 서버 재시작 시 `apply_state.json` 을 읽어 미완료 apply 를 처리한다. 분기: - **country_pending 해소 검사**: 재부팅으로 라이브 country 가 target 과 일치하면 pending 해제(watchdog 영구 억제 방지). `fast=True` 로 hung wpa_cli 의 startup 블록(~19s) 회피. - **VALIDATING / SNAPSHOT 에서 죽음** → 파일/DB 무접촉 단계이므로 변경 0 → `ABORTED` 마킹만(거짓 FAILED_CRITICAL 금지, subprocess 없어 인라인). - **WRITING / APPLYING / VERIFYING / CONFIRM_WAIT / ROLLING_BACK 에서 죽음** → 라이브가 건드려진 상태 → **롤백 필요**. 여기서 **async 가 중요하다**: 롤백은 subprocess(apply.service 75s + wpa 45s + recover 60s + forensic)라 동기로 돌리면 HTTP `bind`/`serve_forever` 를 분 단위로 막아 운영자 lockout 이 된다. 그래서 빠른 판정(파일 read)만 인라인으로 하고, **느린 롤백은 데몬 스레드로 띄우고 즉시 `True`(복구 INITIATED) 반환** 한다(`apply_engine.py:646`). `block=True`(테스트)면 인라인 실행해 terminal state 를 동기 검증한다. ### 3.7 상태머신 다이어그램 #### Mermaid stateDiagram ```mermaid stateDiagram-v2 [*] --> VALIDATING : apply_async / _begin (단일 in-flight, BUSY 거절) VALIDATING --> NOOP : diff 없음 (적용본 == DB) VALIDATING --> FAILED_VALIDATION : validator 에러 / country 게이트 거부 VALIDATING --> SNAPSHOT : diff 有 & 검증 통과 SNAPSHOT --> WRITING : 백업 완료 (snapshot_dir 설정) WRITING --> COMMITTED : country-only deferred (country_pending=True) WRITING --> APPLYING : 일반 적용 APPLYING --> ROLLING_BACK : country_now 강화검증 실패 APPLYING --> VERIFYING VERIFYING --> ROLLING_BACK : verify fail & !force VERIFYING --> CONFIRM_WAIT : eth1 변경 (90s monotonic TTL) VERIFYING --> COMMITTED : eth1 무변경 (또는 verify fail & force) CONFIRM_WAIT --> COMMITTED : confirm(apply_id) (새 IP 재접속) CONFIRM_WAIT --> ROLLING_BACK : tick() TTL 만료 -> 자동롤백 ROLLING_BACK --> ROLLED_BACK : 복원+재적용+재검증 OK ROLLING_BACK --> FAILED_CRITICAL : 재검증 fail -> recover 유닛 -> 실패 note right of VALIDATING 스냅샷 전 예외 -> ABORTED (라이브 무접촉, 거짓 CRITICAL 금지) end note note right of ROLLING_BACK 기준 = 복원된 persist (NEW 기준 X -> 가짜 CRITICAL 방지) end note NOOP --> [*] FAILED_VALIDATION --> [*] COMMITTED --> [*] ROLLED_BACK --> [*] FAILED_CRITICAL --> [*] ABORTED --> [*] ``` #### ASCII 폴백 ```text apply_async -> _begin (BUSY? -> 409) | v +-------------+ | VALIDATING | +-------------+ diff 없음 | | 검증/게이트 실패 | 통과 v v v [NOOP] [FAILED_VALIDATION] +----------+ | SNAPSHOT | (이전: 라이브 무접촉 +----------+ -> 예외 시 [ABORTED]) | v +---------+ country-only deferred | WRITING |----------------------+ +---------+ (country_pending) | | 일반 | v | +----------+ | | APPLYING |-- country_now 강화 | +----------+ 검증 실패 --+ | | | | v | | +-----------+ | | | VERIFYING | | | +-----------+ | | verify fail & !force | | eth1 변경 | | | v | | | +--------------+ | | | | CONFIRM_WAIT | | | | | (90s mono TTL)| | | | +--------------+ | | | | TTL 만료 | confirm | | v (tick) | (새 IP) | v v v v +--------------+ [COMMITTED] <---------+ | ROLLING_BACK | (eth1 무변경 / +--------------+ force commit 포함) 복원+재적용+재검증 | | 재검증 fail OK v v -> recover 유닛 -> 실패 [ROLLED_BACK] [FAILED_CRITICAL] ``` `_ACTIVE` = `(VALIDATING, SNAPSHOT, WRITING, APPLYING, VERIFYING, ROLLING_BACK)`(`apply_engine.py:9`). 종료 상태(terminal): `NOOP`, `FAILED_VALIDATION`, `ABORTED`, `COMMITTED`, `ROLLED_BACK`, `FAILED_CRITICAL`. 진행/대기 상태: `_ACTIVE` + `CONFIRM_WAIT`. ## 4. 22-Field Contract & Byte-Exact Rendering / 22항목 계약·byte-exact 렌더 이 섹션은 **핵심 데이터 계약**을 설명한다. web configurator 는 네트워크 설정을 SQLite `board_config.device_config` 에 저장만 하고, 실제 OS 반영(wpa_supplicant / systemd-networkd)은 dpworldapp 이 자기 재시작 시점에 수행한다([§1.1](#1-overview--motivation--features--개요동기기능)). 따라서 우리가 쓰는 파일이 dpworldapp 이 쓰는 파일과 **논리값·바이트 단위로 정확히 같아야** 재시작 시 출렁임이 없다. 그 일치 단위가 바로 **22항목(22-field) 집합**이다. 핵심 코드는 두 모듈이다. - `src/network/netmodel.py` — DB `device_config` → 중간 표현 `intent` 변환 + 비교 의미론(comparison semantics) - `src/network/renderer.py` — `intent` → dpworldapp byte-호환 출력 파일 6종 렌더 ### 4.1 22항목 매핑: DB device_config key ↔ intent ↔ network_config.json dpworldapp 의 필드 비교 로직은 **18 스칼라 + 4 배열 = 22항목**을 비교한다(deployed-binary 검증 기준, spec §3.4). 우리 `intent_from_device(dev)`(`netmodel.py:47`)는 이 22항목을 정확히 같은 의미로 DB 에서 읽어 `intent` dict(`wlan0`/`eth0`/`eth1` 3 인터페이스 그룹)로 구성한다. `intent` 는 이후 비교·렌더·diff 의 단일 기준점이다. | # | intent path | DB device_config key | network_config.json 위치 | 비고 | |---|---|---|---|---| | 1 | `wlan0.mode` | `wifi_static` (on/off) | `wlan0.mode` ("static"/"dhcp") | `_s(g("wifi_static"))=="on"` → static (`netmodel.py:53`) | | 2 | `wlan0.ip` | `wifi_ip` | `wlan0.ip` | static 일 때만 파싱 | | 3 | `wlan0.netmask` | `wifi_netmask` | `wlan0.netmask` | | | 4 | `wlan0.gateway` | `wifi_gateway` | `wlan0.gateway` | u32==0 이면 [Route] 생략 | | 5 | `wlan0.dns1` | `wifi_dns1` | `wlan0.dns1` | **dead 필드**(렌더 미출력, spec §3.4/§12-3) | | 6 | `wlan0.dns2` | `wifi_dns2` | `wlan0.dns2` | dead 필드 | | 7 | `wlan0.country_code` | `wifi_country_code` | `wlan0.country_code` | `_norm_country`(2 alpha, trim/upper) | | 8 | `eth0.mode` | (`lte_ip` u32!=0 → static) | `eth0.mode` | `eth0_static = _ip_u32(g("lte_ip")) != 0`(`netmodel.py:56`) | | 9 | `eth0.ip` | `lte_ip` | `eth0.ip` | eth0 = LTE uplink | | 10 | `eth0.netmask` | `lte_netmask` | `eth0.netmask` | | | 11 | `eth0.gateway` | `lte_gateway` | `eth0.gateway` | | | 12 | `eth0.server_ip` | `lte_server_ip` | `eth0.server_ip` | host route 판정에 영향 | | 13 | `eth0.server_port` | `lte_server_port` | `eth0.server_port` | JSON number 로 기록 | | 14 | `eth1.mode` | (`eth_ip` u32!=0 → static) | `eth1.mode` | `eth1_static = _ip_u32(g("eth_ip")) != 0`(`netmodel.py:57`) | | 15 | `eth1.ip` | `eth_ip` | `eth1.ip` | eth1 = local ethernet | | 16 | `eth1.netmask` | `eth_netmask` | `eth1.netmask` | | | 17 | `eth1.gateway` | `eth_gateway` | `eth1.gateway` | spec §12-2 회귀 주의 | | 18 | `eth1.opc_ua_server_ip` | `opc_ua_server_ip` | `eth1.opc_ua_server_ip` | **서버 필드지만 네트워크 일관성 집합 포함** | | 19 | `eth1.opc_ua_server_port` | `opc_ua_server_port` | `eth1.opc_ua_server_port` | JSON number | | 20 | `eth1.modbus_server_ip` | `modbus_server_ip` | `eth1.modbus_server_ip` | | | 21 | `eth1.modbus_server_port` | `modbus_server_port` | `eth1.modbus_server_port` | JSON number | | 22 | `wlan0.profiles[]` | `WIFI_SSID[]` (각 entry `wifi_ssid`/`wifi_passwd`/`wifi_security`) | `wlan0.saved_wifi_list[]`(`ssid`/`password`/`security`) | 배열 4항목(ssid/passwd/security/country)을 합쳐 1 그룹으로; 최대 5개(`MAX_PROFILES`) | 이 키 집합은 `netmodel.NETWORK_DEV_KEYS`(`netmodel.py:11`)에 **단일 출처(single source)** 로 동결되어 있다. `apply_engine.py:120` 의 write-allowlist 와 `net_routes.py:38` 의 unknown-key 거부가 모두 이 frozenset 에서 파생되므로, 비-네트워크 키(예: `rs485_databits`)는 apply 경로로 `device_config` 를 변형할 수 없다(drift 차단, [§2.2](#2-architecture--module-map--아키텍처모듈-지도)·[§6 입력 검증 가드](#6-http-api-contract--http-api-계약)). frozenset 는 20개 device_config 키(서버 4필드는 `opc_ua_server_*`/`modbus_server_*` 2+2, `lte_server_*` 는 키 `lte_server_ip`/`lte_server_port` 로 포함)이고, intent 수준 비교 항목은 22개다 — "20 키 ↔ 22 항목" 차이는 `WIFI_SSID` 배열이 1 키이지만 4 비교항목(ssid/passwd/security/country)으로 펼쳐지기 때문이다. > 주의: 서버 엔드포인트 필드(#18~21)는 UI 상 '서버 설정' 페이지 소속이지만 dpworldapp 비교 집합에 포함된다. dpworldapp 의 persist 로드 경로는 이 4필드를 JSON 에서 **다시 읽지 않으므로**(spec §3.3-4 버그, [§10.4](#10-acceptance--security--known-limitations--수락-결과보안한계백로그)), 설정된 장비에서는 필드 비교가 매번 false → 시작할 때마다 재적용이 발생한다. 이것이 byte-exact 불변식을 필수로 만든 직접 원인이다. ### 4.2 렌더되는 6개 파일과 형식 `renderer.RENDER_FILES`(`renderer.py:98`)는 파일명 → 렌더 함수의 공용 상수이며, `apply_engine.py:99` 와 `snapshot.py:87` 가 동일하게 소비한다. 모든 파일은 `/home/root/network/` 에 tmp+rename 으로 원자적 기록된다. #### 4.2.1 network_config.json — `render_persist_json` (renderer.py:57) persist 원본. 22항목 전체를 `wlan0`/`eth0`/`eth1` 3 객체로 직렬화한다. `eth0` 에는 `"role":"lte_uplink"`, `eth1` 에는 `"role":"local_ethernet"` 상수가 붙는다. profiles 는 `saved_wifi_list` 로 나가되 **effective(절단된)** 목록만 기록한다(아래 §4.4). 서식은 무관하나 **값·타입**은 정확해야 하므로(spec §4.1), port 는 `_port_num` 으로 JSON number 캐스팅된다(`renderer.py:51`). ```python return json.dumps(doc, indent=2, ensure_ascii=False) + "\n" ``` #### 4.2.2 10-{wlan0,eth0,eth1}.network — `render_network_file` (renderer.py:16) systemd-networkd 드롭인. metric 은 인터페이스별로 하드코딩(`METRICS = {"wlan0":100, "eth0":200, "eth1":300}`, `netmodel.py:5`). - **dhcp 모드**: `DHCP=ipv4` + `[DHCPv4]\nRouteMetric=` - **static 모드**: `Address=/`(prefix 는 netmask 에서 계산) - **[Route] 생략 조건**: `gateway_set(c)`(`netmodel.py:83`)가 false — 즉 gateway 의 u32 가 0 또는 None — 이면 `[Route]` 섹션 자체를 출력하지 않는다. 이는 deployed dpworldapp binary 와 동일한 "gateway 없는 static" 표현이며, eth1 기본 라우트 제거에 사용된다(spec §12-2). - **host route(eth0 한정)**: `server_ip` 가 있고 gateway 가 설정되어 있으며 static 이고 `server_ip` 가 LTE subnet **밖**일 때만, Metric 없는 `[Route]\nDestination=/32\nGateway=` 를 추가한다(`renderer.py:26-30`). subnet 판정 불가 시 보수적으로 host route 를 만들지 않는다(`_in_subnet` 의 `except ValueError: return True`). ```ini [Match] Name=eth0 [Network] Address=10.0.0.5/24 [Route] Destination=0.0.0.0/0 Gateway=10.0.0.1 Metric=200 ``` #### 4.2.3 wpa_supplicant-wlan0.conf — `render_wpa_conf` (renderer.py:33) wpa_supplicant 가 **직접 읽는** 파일(복사 없음). 헤더 후 effective profiles 만 슬롯 0..4 로 렌더한다. - 헤더: `ctrl_interface=/var/run/wpa_supplicant\nupdate_config=1\n` + country 가 있으면 `country=XX` - 각 profile: `network={ ... }` 블록 - `security=="none"` → `key_mgmt=NONE`(psk 없음) - 그 외 → `psk="<평문>"` + `key_mgmt=WPA-PSK` - **priority = `5 - i`**(첫 profile 이 priority 5, 가장 높음) - psk 는 평문 passphrase, **SSID escaping 없음**, scan_ssid 없음(숨김 SSID 미지원). escaping 부재가 validator 의 금지문자 규칙(§4.5)을 강제하는 이유다. #### 4.2.4 wifi-country-code — `render_country_file` (renderer.py:47) `wlan0.country_code` 를 그대로 출력. 캡처(golden) 확정: **정확히 2바이트, trailing newline 없음**. ### 4.3 §4.1 byte-exact 불변식 — 왜 중요한가 > **DB(22항목 집합)와 network_config.json + 렌더 4종은 항상 한 트랜잭션처럼 함께, 동일 논리값·동일 바이트로 쓴다.**(spec §4.1) spec §3.3-4 의 load/save 비대칭 버그 때문에 dpworldapp 은 **재시작/재부팅마다 무조건** JSON+렌더를 다시 쓰고 `dpworld-network-apply.service` 를 기동한다. 이 재적용을 막는 것은 불가능하므로, 막을 수 있는 유일한 방법은 **재적용을 no-op 으로 만드는 것**이다. - 우리 렌더가 dpworldapp 렌더와 **byte-동일**하면 → apply.sh 의 `cmp -s` 가 무변화 판정 → networkd/wpa 무접촉 → **재적용 전체가 no-op**(네트워크 출렁임 0). - **1바이트라도 다르면** → dpworldapp 재시작/재부팅마다 networkd reload 가 발생(주기적 단절). 따라서 `renderer.py` 의 상수(metric 값, 줄바꿈, `priority=5-i`, country 2바이트, JSON port 타입 등)는 미관이 아니라 **안정성 요건**이다. 캡처와 다르면 본 모듈 상수를 캡처에 맞춘다(`renderer.py:2-3` 주석). 수락 테스트 #8 은 "우리 apply 후 `systemctl restart dpworldapp` → 렌더 byte-무변화 + networkd reload 이벤트 0건"으로 이를 직접 증명한다(spec §10, [§10.1](#10-acceptance--security--known-limitations--수락-결과보안한계백로그)). golden-file 테스트가 ZERO mismatch 를 회귀 가드한다. (단 #8 검증에서 dpworldapp 이 `network_config.json` 만은 자체 cJSON tab 포맷으로 재직렬화함이 확인돼, JSON 은 "값 동일성", networkd 렌더 파일은 "byte-exact" 로 불변식이 정밀화됐다 — [§10.1](#10-acceptance--security--known-limitations--수락-결과보안한계백로그)의 정정 참조.) ### 4.4 비교 의미론 (comparison semantics) `intent_from_device` 와 `_canon`(`netmodel.py:87`)은 dpworldapp 의 strict dotted-quad 파싱 후 u32 비교를 미러링한다. 표기가 달라도 같은 IP 면 같다고 판정해야 false drift 를 피한다. | 의미론 | 동작 | 코드 | |---|---|---| | **zero-동치** | `''` ≡ `0.0.0.0` (둘 다 u32 0) | `_ip_u32("")==0`(`netmodel.py:30`) | | **dhcp zero-out** | dhcp 모드면 ip/netmask/gateway/dns 를 빈 값으로 — dpworldapp 이 dhcp 모드 필드를 파싱하지 않으므로 DB 잔존값 무시 | `net()`(`netmodel.py:58-62`) | | **strict dotted-quad** | 정확히 4 octet, 선행 0 거부(`p[0]=='0'` 이고 길이>1), octet>255 거부, **ASCII digit 만**(unicode 거부) | `_ip_u32`(`netmodel.py:25-41`) | | **port number 동치** | `"20111" == 20111` — port 는 문자열로 정규화 후 비교 | `_canon` port leaf(`netmodel.py:95-96`) | | **gap 절단** | profiles 는 첫 빈 SSID 에서 중단(effective) — 빈 trailing 행은 변경 아님 | `effective_profiles`(`netmodel.py:74`) | | **legacy 'Open'→none tolerant read** | DB 의 legacy `"open"` 값을 읽을 때 `"none"` 으로 정규화(거부 아님) | `_norm_security`(`netmodel.py:43-45`) | | **unparseable → static + raw 보존** | ip 가 파싱 불가(u32==None)면 dhcp 가 아닌 static 으로 두고 원문 보존 → validator 가 명확한 에러로 거부 | `netmodel.py:54-57` | `_canon` 은 비교가 안 되는 값(`_ip_u32` 가 None)은 `("raw", s)` 로 폴백해 원문 문자열 비교로 떨어진다(`netmodel.py:94`). `diff_intents`(`netmodel.py:114`)는 이 정규화로 변경 필드 목록 `[{field, old, new}]` 을 만들어 UI diff 미리보기·drift 뱃지에 공용으로 쓰며([§8.2.5](#8-ui--사용자-인터페이스)), profiles 는 effective 기준으로 비교하되 password-only 변경은 "(자격증명 변경)" 마커를 붙인다(`netmodel.py:130-131`). 역변환은 `intent_from_persist`(`renderer.py:75`)가 담당한다. network_config.json → intent 로 되돌려 watchdog 판정(§5)과 golden 역변환에 쓰며, null/non-dict 에 tolerant 하고(`(doc.get("iface") or {})`), `saved_wifi_list` 에서 dict 아닌 entry 와 빈 ssid 는 걸러낸다(`renderer.py:88`). ### 4.5 검증 규칙 (validator §5.2) `validate(intent)`(`validator.py:39`)는 dpworldapp 파서/렌더의 한계를 데이터로 위반하지 않기 위한 hard rule 이다. `(errors, warnings)` 를 반환하며 errors 가 하나라도 있으면 적용을 거부한다(spec §5.2). | 항목 | 규칙 | 상수/코드 | |---|---|---| | **security enum** | `{"wpa/wpa2", "none"}` 만 허용 — 그 외는 dpworldapp 이 프로파일 통째 삭제 | `ALLOWED_SECURITY`(`validator.py:5`, 검사 `:63`) | | **SSID 바이트길이** | UTF-8 기준 ≤ 19 byte(dpworldapp 20byte 버퍼 snprintf 절단) | `MAX_SSID=19`(`validator.py:6`, `:57-59`) | | **psk 바이트길이** | `wpa/wpa2` 면 8~19 byte; `none` 이면 password 빈 값 강제 | `PSK_MIN/MAX=8,19`(`validator.py:7`, `:65-71`) | | **금지문자** | SSID·password 에 `"`, `\`, 제어문자(ord<0x20) 금지 — wpa 렌더에 escaping 없음 | `_has_forbidden_char`(`validator.py:25-27`) | | **netmask 연속성** | static 이면 연속(contiguous) netmask 필수(예 `255.255.255.0`) | `_netmask_contiguous`(`validator.py:17-23`, `:86`) | | **포트 범위** | 빈 값 허용; 아니면 ASCII digit + 1~65535 | `_port_ok`(`validator.py:29-37`), 검사 대상 `eth0.server_port`/`eth1.opc_ua_server_port`/`eth1.modbus_server_port`(`:104-112`) | | **gap 금지** | 첫 빈 SSID 뒤에 비어있지 않은 profile 이 있으면 거부 — trailing 빈 슬롯은 허용 | `validator.py:48-52` | | **profile 개수** | 최대 5개(`MAX_PROFILES`) | `validator.py:8`, `:44-45` | | **country** | 비어있거나, 2자리 **대문자** 알파벳 | `validator.py:75-77` | | **static IP 정합** | static 인데 IP 없음/형식오류 거부; gateway 빈 값 허용([Route] 생략) | `validator.py:78-102` | 경고(warnings, 비차단)로는 gateway 가 subnet 밖인 경우(`validator.py:99-100`)와 인터페이스 간 서브넷 중복(`validator.py:113-124`)이 있으며, 후자는 모든 pair 비교를 `try/except ValueError` 로 감싸 절대 throw 하지 않는다. ### 4.6 요약 22항목은 deployed dpworldapp binary 검증 기준에서 도출된 비교 단위이고, `netmodel.NETWORK_DEV_KEYS` 가 그 단일 출처다. `intent` 가 DB ↔ 비교 ↔ 렌더 ↔ diff 의 공통 기준이며, 렌더 6 파일은 `RENDER_FILES` 로 동결된다. byte-exact 불변식이 핵심인 이유는 dpworldapp 의 매-재시작 재적용을 막을 수 없고 **no-op 으로 만들 수밖에 없기** 때문이다. 비교 의미론(zero-동치/dhcp zero-out/strict quad/port number/gap 절단/legacy tolerant read)은 dpworldapp 의 strict dotted-quad·정규화 동작을 미러링해 false drift 를 막고, validator(§4.5)는 dpworldapp 파서가 데이터를 silent 하게 손상시키는 모든 경계를 사전 차단한다. ## 5. Watchdog & Self-Healing / 상시 감시·자가복구 `NetworkWatchdog`(`src/network/watchdog.py`)는 별도 daemon thread 에서 일정 주기로 network 상태를 점검하고, 의도(intent)와 어긋난 항목을 정해진 복구 사다리(recovery ladder)로 자가복구하는 컴포넌트다. 핵심 설계 철학은 두 가지다. 첫째, 판정 기준은 사용자가 화면에서 추측한 값이 아니라 **실제 적용본인 `network_config.json`** 이다. 둘째, watchdog 자신은 **절대 죽지 않으며**, 동시에 잘못된 복구로 production network 를 흔들지 않도록 다층 억제·안전장치를 둔다. ### 5.1 30초 틱(tick)과 판정 기준 watchdog 의 심장은 `tick_once()`(`watchdog.py:205`)이며, `start()`(`watchdog.py:293`)가 띄운 daemon thread 의 `loop()` 가 이를 반복 호출한다. ```python def loop(): while True: try: self.tick_once() time.sleep(self._cfg()["interval_s"]) except Exception: # noqa: BLE001 — watchdog 은 절대 죽지 않는다 time.sleep(30) # I2: sleep 도 try 안 — _cfg 예외로 thread 사망/tight-loop 금지 ``` 틱 주기(interval)는 `_cfg()`(`watchdog.py:64`)가 DB 의 `net_config` 키에서 읽는 `watchdog_interval_s` 값이며, 기본 30초다. 단 안전을 위해 **10~300초로 clamp** 된다(`watchdog.py:72`): ```python interval = max(10, min(300, int(cfg.get("watchdog_interval_s", 30)))) ``` 값이 정수로 변환 불가하거나(`TypeError`/`ValueError`) `net_config` 자체가 dict 이 아니면(list 등 이형) 모두 기본값 30 으로 폴백한다 — 잘못된 config 로 thread 가 죽는 것을 막기 위함이다(`watchdog.py:70-74`). **판정 입력**은 `_intent()`(`watchdog.py:80`)가 만든다. `net_dir/network_config.json` 을 읽어 `renderer.intent_from_persist()` 로 변환한 "적용본 의도"가 모든 체크의 기준점이다. 파일이 없거나(`OSError`) 손상되면(`ValueError`) `None` 을 반환하고, 이 경우 `_collect_checks()` 는 빈 dict 을 돌려주어(`watchdog.py:108-109`) 그 틱은 아무것도 복구하지 않는다. ### 5.2 체크 5종 (health checks) `_collect_checks()`(`watchdog.py:104`)는 `name → bool(healthy)` 형태의 dict 을 만든다. 이름은 `wlan_module`, `wpa_state`, `addr_route`, `eth0`, `eth1` 이다. | 체크 이름 | 조건 | 판정 방식 | 근거 | |---|---|---|---| | `wlan_module` | WiFi profile 이 설정됨(`netmodel.effective_profiles(it)`) | `/sys/module/wlan` 디렉터리 + `/sys/class/net/wlan0` 존재 | `watchdog.py:114` | | `wpa_state` | 동상 | `wpa_cli -i wlan0 status` 출력에 `wpa_state=COMPLETED` 포함 | `watchdog.py:116-119` | | `addr_route` | `wlan0.mode == "static"` | `ip -br addr show wlan0` 에 의도 IP **정확 일치** + gateway 설정 시 `ip route show` 에 `default via dev wlan0` 존재 | `watchdog.py:124-130` | | `eth0` / `eth1` (per-iface) | 각 iface `mode == "static"` 이고 IP 있음 | `ip -br addr show ` 에 의도 IP 정확 일치 | `watchdog.py:131-156` | 체크들은 WiFi 가 실제 설정된 경우(`wifi_configured`)에만 `wlan_module`/`wpa_state`/`addr_route` 를 평가하고, eth 는 각각 static + IP 가 있을 때만 키를 만든다. 즉 설정되지 않은 항목은 아예 체크 dict 에 들어가지 않아 오탐을 원천 차단한다. **주소 매칭 정확성** — `_addrs_in()`(`watchdog.py:27`)은 `ip -br addr` 출력을 파싱해 prefix(`/24`)를 떼고 주소 목록만 추출한다. 단순 substring 비교가 아니라 정확 일치를 쓰는 이유는, 설정값 `192.168.55.5` 가 라이브 `192.168.55.54/24` 에 substring 으로 잘못 매칭되던 결함(M2) 때문이다. **wpa 쿼리 실패는 UNKNOWN** — `wpa_cli status` 가 `rc != 0` 이면 `wpa_state` 키를 아예 **생략**한다(`watchdog.py:120-123`). 부팅 race 로 wpa_supplicant 제어 소켓이 아직 준비 안 된 상황을 unhealthy 로 오판해 production wpa 를 flap 시키지 않기 위함이다. 이때 `_note_wpa_query(False)`(`watchdog.py:95`)가 transition 시 1회만 WARN 을 남긴다(C3). ### 5.3 복구 사다리 (_LADDERS) 와 단계 선택 복구 절차는 `_LADDERS`(`watchdog.py:17`)와 per-iface 처리(`_ladder_for`, `watchdog.py:37`)로 정의된다. 각 사다리는 `[단계1, 단계2, ...]` 이고 각 단계는 실행할 `argv` 목록이다. ```python _LADDERS = { "wlan_module": [[["systemctl", "start", "dpworld-net-recover.service"]]], "wpa_state": [[["wpa_cli", "-i", "wlan0", "reconfigure"]], [["systemctl", "restart", "wpa_supplicant@wlan0.service"]]], # §3.3-5: apply.service 단독은 gateway/metric-only 변경 침묵 누락 -> reload 동반 필수 "addr_route": [[["networkctl", "reconfigure", "wlan0"]], [["systemctl", "start", APPLY_SERVICE], ["networkctl", "reload"]]], } ``` | 체크 | 1단계 | 2단계 | |---|---|---| | `wlan_module` | `dpworld-net-recover.service` 시작 | (없음) | | `wpa_state` | `wpa_cli reconfigure` | `wpa_supplicant@wlan0.service` 재시작 | | `addr_route` | `networkctl reconfigure wlan0` | apply.service 시작 **+** `networkctl reload`(2단 reload) | | `eth0`/`eth1` | `networkctl reconfigure `(실패한 그 iface) | (없음) | **`addr_route` 의 2단 reload(§3.3-5)** 가 중요하다. 2단계에서 apply.service 단독으로는 gateway/metric-only 변경이 침묵 누락되므로 `networkctl reload` 를 반드시 동반한다. **단계 선택 — 사다리를 한 칸씩 오른다.** `tick_once()` 안에서 `ladder_idx = max(0, self._fail_streak[name] - 2)`(`watchdog.py:263`)로 계산한다. 히스테리시스 2 를 넘긴 직후(streak=2)에는 idx=0(가벼운 1단계), 복구해도 계속 실패하면 streak 가 늘며 idx 가 올라가 더 강한 단계로 에스컬레이션한다. `_do_recover()`(`watchdog.py:183`)가 `min(ladder_idx, len(ladder)-1)` 로 사다리 끝을 clamp 한 뒤 단계 내 모든 argv 를 순차 실행하며, 각 명령의 성공/실패를 journal 에 `ok`/`fail` 로 기록한다. ### 5.4 억제 규칙 (suppression) watchdog 은 정당한 다른 작업과 경합하거나 무의미한 churn 을 일으키지 않도록 여러 단계에서 복구를 억제한다. 모두 `tick_once()` 와 하위 함수에 구현되어 있다. | 억제 규칙 | 동작 | 위치 | |---|---|---| | **Grace 120s** | `start()` 직후 `_grace_until = now + GRACE_S(120)`. 그 전까지 틱은 early-return | `watchdog.py:218-221`, `296` | | **apply busy / CONFIRM_WAIT** | `is_busy_or_confirming()`(없으면 `is_busy` 폴백)가 True 면 전체 스킵 — eth1 확인 대기 중 사다리 침범 금지(I1a, [§3.5](#3-apply-flow--state-machine--적용-흐름상태머신)) | `watchdog.py:224` | | **seed/apply.service active** | apply.service 또는 `dpworld-network-seed.service` 가 active 면 전체 스킵(재적용과 경합 금지, §3.3-4) | `watchdog.py:226-228` | | **country-pending** | 전면 정지가 **아니라** apply.service **에스컬레이션만** 금지. `_do_recover` 내에서 해당 argv 만 skip 하고 나머지 사다리는 계속(자가복구 목표 약화 방지) | `watchdog.py:187-191` | | **eth DOWN / NO-CARRIER 복구 억제** | `NO-CARRIER` 또는 `state == "DOWN"` 이면 복구하지 않고 streak 를 0 으로 리셋 후 `continue`. 케이블 churn 으로 5분마다 무의미 reconfigure -> 시간당 한도 도달 -> CRITICAL 자멸을 막음 | `watchdog.py:139-154` | | ↳ **주소 drift WARN** | 위 DOWN 상태에서 iface 가 stale/wrong IP 를 들고 있으면(의도 IP 부재) transition-only WARN 로 가시성만 추가(복구는 여전히 보류, M1) | `watchdog.py:141-152` | country-pending 억제의 정밀함이 핵심이다. country code 변경 보류 중에는 전면 정지하면 자가복구 능력 자체가 약화되므로, `_do_recover()` 안에서 `engine.country_pending()` 이고 argv 에 apply.service 가 포함될 때만 그 명령을 건너뛴다(`watchdog.py:188`). ### 5.5 안전장치 (safeguards) 복구가 폭주하거나 watchdog thread 가 죽는 것을 막는 장치들이다. - **히스테리시스 2** — `_fail_streak[name] < 2` 인 동안은 복구하지 않고 `continue`(`watchdog.py:249`). 일시적 깜빡임을 복구 트리거로 삼지 않는다. - **Cooldown 5분(COOLDOWN_S=300)** — `now - self._last_recover[name] < COOLDOWN_S` 면 skip(`watchdog.py:255`). 같은 체크를 5분 안에 다시 복구하지 않는다. - **시간당 4회 한도(HOURLY_MAX=4) -> critical** — 최근 1시간 발동 시각(`_recover_times`)이 4 이상이면 `_critical = True` 로 잠그고 `critical_stop` 을 journal 에 남긴 뒤 return(`watchdog.py:257-262`). - **critical 자동 재무장 + 수동 reset** — `tick_once()` 가 매 틱 early-return 전에 1시간 창을 청소한다(`watchdog.py:211`). critical 이 잠긴 뒤 1시간 윈도가 비면 스스로 `_critical = False` 로 풀고 `critical_auto_rearmed` 를 남긴다(`watchdog.py:212-215`). 이 평가가 enabled/critical early-return 보다 **앞에** 있어야 잠긴 상태에서도 재무장이 동작한다. 즉시 되살리는 수동 경로는 `reset_critical()`(`watchdog.py:197`)으로, critical 잠금과 1시간 이력을 함께 비운다(config 라우트의 `reset_watchdog_critical` 이 호출, [§6](#6-http-api-contract--http-api-계약)). - **engine.tick() 무조건 호출** — `tick_once()` 첫 줄에서 억제조건·enabled 와 무관하게 항상 `self.engine.tick()` 을 호출한다(`watchdog.py:206`). watchdog 이 멈춰도 confirm TTL(확인 타임아웃) 안전망이 계속 돌게 하기 위함이다([§3.5](#3-apply-flow--state-machine--적용-흐름상태머신)). - **정상 틱 디스크 무기록 + 1h heartbeat** — 정상 틱은 RAM 카운터만 갱신하고 디스크에 쓰지 않는다. `HEARTBEAT_S(3600)` 마다 한 번만 `heartbeat` 이벤트로 현재 체크 결과를 남긴다(`watchdog.py:269-272`). 로그 폭주를 막는다. - **firmware md5 drift 감시** — `start()` 시 1회 `_check_fw_md5()`(`watchdog.py:274`)가 `/usr/bin/dpworldapp` 의 md5 를 계산해 렌더 계약 검증된 빌드 목록(`COMPAT_DPWORLDAPP_MD5`)과 비교하고, 불일치면 `fw_md5_drift` WARN 을 남긴다. `MemoryMax=48M` 제약 하에서 수십 MB 바이너리를 통째 읽지 않도록 **1MB 청크** 로 읽는다(I6, `watchdog.py:280`). - **절대 죽지 않는 loop** — `loop()` 의 `tick_once()` + `time.sleep()` 전체가 `try/except Exception` 으로 감싸여 있고, 예외 시 30초 쉰 뒤 계속한다(`watchdog.py:298-304`). sleep 까지 try 안에 둔 이유는 `_cfg()` 예외로 thread 가 죽거나 tight-loop 에 빠지지 않게 하기 위함이다(I2). 추가로, gateway ping 체크(체크 4)는 자동복구를 절대 하지 않는다. `_gateway_ping_warn()`(`watchdog.py:168`)는 `_tick_count % 2 == 0` 일 때, 즉 **2틱마다(interval×2)** 실행되며 ping 실패 transition 시 WARN 로그만 남긴다(`watchdog.py:267-268`). 과거 auth-loop 사고의 교훈에 따른 의도적 WARN-only 설계다. 또한 새 apply 가 지나가면 구 구성 기준의 상태를 무효화한다. `engine.status().get("apply_id")` 가 직전 값과 다르면 `_fail_streak`/`_ping_state`/`_carrier_state` 를 모두 비운다(I1b, `watchdog.py:230-240`). ### 5.6 Kill-switch (net_config 튜너블 3종) `_cfg()`(`watchdog.py:64-77`)가 DB `net_config` 키에서 읽는 3개 값이 watchdog 의 외부 제어 손잡이다. 이는 `log_config` 키 분리 전례를 따른 별도 키 패턴이다(API 는 [§6](#6-http-api-contract--http-api-계약), 운영 절차는 [§9.4.4](#9-deployment--operations--배포운영)). | 키 | 기본값 | 효과 | |---|---|---| | `watchdog_enabled` | `"on"` | `"on"` 이 아니면 `tick_once()` 가 복구 없이 early-return(`watchdog.py:216-217`). **단 engine.tick()/critical 재무장은 계속** | | `watchdog_auto_recover` | `"on"` | `"on"` 이 아니면 unhealthy 를 감지해 `detect` WARN 만 남기고 실제 복구는 하지 않음(`watchdog.py:251-254`) | | `watchdog_interval_s` | `30`(10–300 clamp) | 틱 주기. 잘못된 값은 30 으로 폴백(`watchdog.py:72-74`) | `watchdog_enabled` 와 `watchdog_auto_recover` 의 차이가 중요하다. 전자는 watchdog 의 능동 동작 전체를 끄지만 confirm TTL 안전망(engine.tick)과 critical 재무장은 살려두고, 후자는 감시·로깅은 유지하되 시스템에 손대는 복구 행위만 끈다. `snapshot()`(`watchdog.py:289`)은 현재 `enabled`/`critical`/`fail_streak`/`last_results` 를 노출해 운영자가 상태를 조회할 수 있게 한다([§8.2.3](#8-ui--사용자-인터페이스)). ## 6. HTTP API Contract / HTTP API 계약 Network apply subsystem 은 `/api/network/*` 아래 7개 endpoint 를 제공한다. 라우팅은 `src/server.py` 의 `do_GET`(445-459) / `do_POST`(595-626) 분기에서 시작하고, request body 검증을 통과한 dict 를 `src/network/net_routes.py` 의 `NetworkRoutes` 메서드로 위임한다([§2.3](#2-architecture--module-map--아키텍처모듈-지도)). 실제 상태머신 결과는 `src/network/apply_engine.py` 의 `ApplyEngine` 가 만든다([§3](#3-apply-flow--state-machine--적용-흐름상태머신)). 전 endpoint 는 server-independent 한 dict-in / dict-out 규약을 따른다(`net_routes.py:1`). server 계층은 단지 HTTP body 를 parse 해 dict 를 넘기고, route 가 돌려준 dict 를 JSON 으로 직렬화할 뿐이다. ### 6.1 엔드포인트 일람 | Method + Path | Request body | Success Response | 비고 | |---|---|---|---| | `GET /api/network/state` | 없음 | `{interfaces, interfaces_ok, drift, watchdog, last_apply, country_pending}` | live ip 표 + drift + watchdog + 최근 apply 상태 종합(`net_routes.py:71`) | | `POST /api/network/apply` | `{fields:{}, dry_run?, country_now?, force?}` | dry_run: `{ok, diff, errors, warnings}` / real: `{ok, state:"STARTED", apply_id}` | 비동기. real 적용은 즉시 `apply_id` 반환 후 폴링(`net_routes.py:31`) | | `GET /api/network/apply/status?id=` | 없음 (query `id`) | `{apply_id, state, steps[], confirm_remaining_s, country_pending}` | 폴링이 confirm TTL 타이머 역할 겸함(`apply_engine.py:581`) | | `POST /api/network/apply/confirm` | `{apply_id}` | `{ok, state:"COMMITTED", ...}` | eth1 변경 시 `CONFIRM_WAIT` 확정(`net_routes.py:56`) | | `POST /api/network/rollback` | 없음 | `{ok, state:"ROLLED_BACK"\|"FAILED_CRITICAL", apply_id}` | last-known-good 스냅샷 수동 롤백(`net_routes.py:63`) | | `GET /api/network/config` | 없음 | `{net_config:{watchdog_*}}` | watchdog kill-switch 현재값(기본값 merge)(`net_routes.py:96`) | | `POST /api/network/config` | `{watchdog_enabled?, watchdog_auto_recover?, watchdog_interval_s?}` 또는 `{reset_watchdog_critical:true}` | `{ok, net_config:{...}}` | watchdog 토글 + critical 재무장(`net_routes.py:99`) | | `GET /api/network/journal?limit=` | 없음 (query `limit`) | `{events:[...]}` | apply 저널 tail. limit 1-500, 파싱 불가 시 50 폴백(`net_routes.py:78`) | ### 6.2 POST /api/network/apply — 핵심 동작 `apply()`(`net_routes.py:31`)의 분기 로직: - `fields` 가 dict 가 아니면 `RouteError(400, "fields must be an object")`. - `dry_run` 이 true 면 `engine.dry_run(fields, country_now=...)` 를 동기 실행해 `{diff, errors, warnings}` 를 반환(라이브 무접촉, 검증·게이트 미리보기). - `dry_run` 이 아니면 `engine.apply_async(...)` 로 비동기 시작. eth1 IP 변경 시 networkctl 적용 후엔 구 연결로 응답할 수 없으므로 동기 실행을 금지하고 `apply_id` 를 즉시 돌려준다(`net_routes.py:44`). - 이미 in-flight apply 가 있으면 engine 이 `state:"BUSY"` 를 돌려주고, route 는 이를 `RouteError(409, "apply in flight: ")` 로 변환. #### country_now / force 의 의미 - `country_now` — WiFi country code 변경은 wlan 모듈 리로드(10-20s 단절)를 유발하므로 기본은 재부팅까지 deferred 다. `country_now=true` 동의 시 다른 변경과 함께 즉시 적용한다(`apply_engine.py:365` 의 §6.2 전문가 즉시 적용 경로, [§3.3](#3-apply-flow--state-machine--적용-흐름상태머신)). dry_run 단계에서도 동일 게이트가 미리 노출되어 dry_run OK 인데 apply 거절되는 divergence 를 방지한다(`apply_engine.py:212`). - `force` — verify 실패 시에도 롤백하지 않고 강제 commit(`apply_engine.py:386`, 저널에 `force_commit` warn 기록). ### 6.3 입력 검증 가드 (강조) 이 subsystem 은 4개의 엄격한 입력 가드로 silent 변형·side-door 를 차단한다. #### 1. fields 네트워크 allowlist (`NETWORK_DEV_KEYS`) `apply()` 는 `fields` 의 모든 키가 `netmodel.NETWORK_DEV_KEYS`(`netmodel.py:11`, 20개 네트워크 키)에 속하는지 검사하고, 하나라도 벗어나면 거절한다(`net_routes.py:38`): ```python unknown = sorted(k for k in fields if k not in netmodel.NETWORK_DEV_KEYS) if unknown: raise RouteError(400, f"unknown network field(s): {', '.join(unknown)}") ``` 이는 apply 가 임의 `device_config` 키를 쓰면 스냅샷(20 네트워크 키)이 그 변경을 못 잡아 롤백으로도 제거 불가해지는 결함, 그리고 `/setting/device` 의 enum 검증을 우회하는 side-door 를 막는다. 같은 allowlist 가 snapshot 키 목록의 단일 출처이기도 해서(`apply_engine.py:120`), write-allowlist 와 snapshot-keys 간 drift 가 발생하지 않는다([§2.2](#2-architecture--module-map--아키텍처모듈-지도)·[§4.1](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더)). dry_run·real apply 양쪽에 동일 적용된다(`net_routes.py:36`). `NETWORK_DEV_KEYS` 구성: `wifi_static, wifi_ip, wifi_netmask, wifi_gateway, wifi_dns1, wifi_dns2, wifi_country_code, WIFI_SSID, eth_ip, eth_netmask, eth_gateway, lte_ip, lte_netmask, lte_gateway, lte_server_ip, lte_server_port, opc_ua_server_ip, opc_ua_server_port, modbus_server_ip, modbus_server_port`. #### 2. 엄격 bool 파서 (`_req_bool`) `dry_run` / `country_now` / `force` 는 `_req_bool`(`net_routes.py:11`)로 파싱한다. JSON 문자열 `"false"` 가 Python truthiness 로 True 가 되어 `force`/`country_now` 가 잘못 활성화되는 결함을 차단한다. `None`(미지정)=False, 진짜 `bool` 만 수용, 그 외엔 거절: ```python if v is None: return False if isinstance(v, bool): return v raise RouteError(400, f"{name} must be a boolean") ``` 즉 `{"dry_run":"false"}` 같은 문자열 body 는 `400 dry_run must be a boolean` 으로 거절된다(silent coercion 금지). #### 3. config 엄격 int + 화이트리스트 `POST /api/network/config`(`net_routes.py:99`)는 watchdog 키만 쓰기 허용하는 엄격 화이트리스트다. - `watchdog_enabled` / `watchdog_auto_recover` — `"on"`/`"off"` 만 허용, 그 외 400. - `watchdog_interval_s` — `int(v)` 가 float `10.9` 를 silently 10 으로 truncate 하는 것을 막기 위해 엄격 검사: `bool`(int 서브클래스) 명시 차단 + `int` 아니면 400 + 범위 `[10, 300]` 벗어나면 400(`net_routes.py:118`). - 미지 키는 `unknown key: ` 로 400, 유효 키가 하나도 없으면 `no valid keys to write` 400. - `reset_watchdog_critical:true` 는 단일 의도이므로 다른 키와 동시 전송 시 400(`net_routes.py:104`). #### 4. 비-dict body 400 server 계층에서 `_read_request_body`(`server.py:251`)가 JSON 을 parse 한 뒤, 각 POST 핸들러가 dict 가 아니면(JSON array `[1,2,3]` / scalar 등) 400 으로 거절한다(`server.py:600`, `:610`, `:622`). 빈 body·Content-Length 이상·과대 payload 도 body reader 가 각각 400/413 으로 차단한다(`server.py:259-271`). route 메서드 내부 `config()` 도 자체적으로 `if not isinstance(body, dict): RouteError(400)` 를 한 번 더 가진다(`net_routes.py:102`). ### 6.4 실제 응답 JSON 예시 dry_run 미리보기(POST /api/network/apply, `{"dry_run":true, "fields":{"wifi_ip":"192.168.55.60"}}`): ```json { "ok": true, "diff": [{"field": "wlan0.ipv4_address", "from": "192.168.55.56", "to": "192.168.55.60"}], "errors": [], "warnings": ["wifi_disrupt"] } ``` real apply 시작(POST /api/network/apply, `{"fields":{"eth_ip":"10.0.0.5"}}`): ```json { "ok": true, "state": "STARTED", "apply_id": "ap-20260614-101530-742-3" } ``` 상태 폴링(GET /api/network/apply/status?id=ap-...): ```json { "apply_id": "ap-20260614-101530-742-3", "state": "CONFIRM_WAIT", "steps": [ {"phase": "VALIDATING", "result": "ok", "detail": "1 fields", "t": "10:15:31"}, {"phase": "APPLYING", "result": "ok", "detail": "", "t": "10:15:36"} ], "confirm_remaining_s": 84, "country_pending": false } ``` 검증 가드 거부 예시(`{"dry_run":"false"}` → 400): ```json { "ok": false, "error": "dry_run must be a boolean" } ``` watchdog 조회(GET /api/network/config): ```json { "net_config": { "watchdog_enabled": "on", "watchdog_auto_recover": "on", "watchdog_interval_s": 30 } } ``` ### 6.5 에러 봉투 (error envelope) 와 503 모든 `/api/network/*` 는 server 측 `_net_json(fn)` 헬퍼(`server.py:682`)를 통과한다. - network subsystem 이 import-time 에 초기화 실패했으면(`_NET is None`, 예: Windows dev box) `503 {"ok": false, "error": "network subsystem unavailable: "}`. - route 가 `RouteError(status, message)` 를 raise 하면 그 status 코드로 `{"ok": false, "error": }`. - 그 외 예외는 traceback 출력 후 `500 {"ok": false, "error": }`. 즉 클라이언트는 비-2xx 응답에서 항상 `{ok:false, error}` 형태를 기대할 수 있다. ### 6.6 보안 posture (한 줄) 전 endpoint 는 미인증이며, 현재는 폐쇄(LAN-only) IoT 네트워크 전제 정책에 의존한다 — 인증·서명 보호는 별도 SEC-2 작업으로 분리되어 있다(상세 자세 평가는 [§10.3](#10-acceptance--security--known-limitations--수락-결과보안한계백로그), 운영 완화는 [§9.5](#9-deployment--operations--배포운영)). ## 7. Snapshot · Rollback · Journal · Forensics / 백업·롤백·저널·포렌식 네트워크 설정을 실제로 적용(apply)하다가 운영자가 SSH/HTTP 접속을 잃는 "lockout" 을 막기 위해, 이 기능은 **변경 직전에 전체 상태를 백업(snapshot)** 하고, 검증이 실패하면 **그 백업으로 자동 복원(rollback)** 한다. 모든 단계는 **JSONL 저널** 에 1줄씩 기록되고, 롤백/치명 실패 시에는 **포렌식 번들**(dmesg/journalctl 등)을 tar.gz 로 수집한다. 비밀(wifi password, wpa psk)은 디스크 보호 권한(0600/0700)과 마스킹으로 보호된다. 관련 파일: - `src/network/snapshot.py` — 백업 생성/검증/복원, last-known-good(LKG), prune - `src/network/apply_engine.py` — 롤백 본문(`_rollback`), DB 필드 캡처/복원, 상태 영속화([§3](#3-apply-flow--state-machine--적용-흐름상태머신)) - `src/network/journal.py` — JSONL 저널 클래스 + 포렌식 번들 ### 7.1 Snapshot — apply 직전 백업 apply 흐름에서 `SNAPSHOT` 단계에 진입하면(`apply_engine.py:327-330`) `snapshot.take()` 가 호출되어 **파일 + DB 네트워크 필드** 두 가지를 한 디렉토리(`backups_dir//`)에 묶어 저장한다. **(a) 무엇을 백업하나** | 대상 | 내용 | 근거 | |------|------|------| | `net_dir` 전체 파일 | `10-eth1.network`, wpa conf, `network_config.json`(persist) 등 렌더 산출물 일체 | `snapshot.take()` `snapshot.py:27-37` | | DB 네트워크 필드 | `device_config` 중 네트워크 키만 추출한 dict → `db_fields.json` | `_db_network_fields()` `apply_engine.py:114-123` | | manifest | 각 파일의 sha256 + `db_fields.json` 의 sha256 + `taken_at` | `snapshot.py:42-49` | DB 필드는 **fmt 2** 형식으로 저장된다(`apply_engine.py:121-123`). 키 목록은 `netmodel.NETWORK_DEV_KEYS` 단일 출처에서 정렬해 파생하므로 write-allowlist 와 snapshot 키 사이 drift 가 생기지 않는다([§2.2](#2-architecture--module-map--아키텍처모듈-지도)). ```python return {"fmt": 2, "present": {k: dev[k] for k in keys if k in dev}, # 존재하는 키 = 값 "absent": [k for k in keys if k not in dev]} # 부재하는 키 = 이름만 ``` `present`/`absent` 를 분리 기록하는 이유: 롤백 시 단순 merge 복원이면 "원래 없던 키" 를 부활시키는 결함이 생긴다. 부재 키를 명시 기록해야 롤백이 `present` 복원 + `absent` pop 으로 **정확히 원상복구** 할 수 있다. **(b) manifest self-verify (sha256)** `take()` 는 백업을 만든 직후 같은 함수 안에서 `verify(dest)` 를 호출하고(`snapshot.py:50-51`), 실패하면 `RuntimeError("snapshot self-verify failed")` 를 던진다. `verify()` 는 manifest 의 각 파일 sha256 과 `db_fields.json` sha256 을 디스크 실파일과 재계산해 대조한다(`snapshot.py:54-64`). manifest 자체도 `manifest.json.tmp` → `os.replace` 원자적 쓰기로 만든다(`snapshot.py:44-48`). **(c) last-known-good (LKG) 포인터** apply 가 최종 `COMMITTED` 되면 `_lkg_bookkeeping(aid)` 가 `mark_last_known_good()` 으로 `backups_dir/last_known_good` 파일에 그 `apply_id` 문자열을 기록한다(`apply_engine.py:410-414`, `snapshot.py:94-99`, 상수 `LKG = "last_known_good"` `snapshot.py:5`). 이 LKG 가 수동 롤백(`rollback_to_lkg`)의 복원 대상이 된다. 이 북키핑은 부가 작업이라 실패해도 COMMITTED 는 유지하고 저널 warn 만 남긴다(`apply_engine.py:411`). **(d) prune — keep 5, LKG 보존** `prune(backups_dir, keep=5)` 는 백업 디렉토리를 mtime 내림차순 정렬 후 상위 5개만 남기고 삭제한다(`snapshot.py:107-124`). 단: - `forensic` 으로 시작하는 항목은 백업 집계에서 제외(`snapshot.py:113`) - 가장 오래됐더라도 **LKG 가 가리키는 디렉토리는 절대 삭제하지 않음**(`snapshot.py:119-120`) - per-dir fail-soft: `listdir` 와 `getmtime` 사이 디렉토리가 사라져도 무시(`snapshot.py:115-116, 123-124`) **(e) fresh-flash 빈 baseline 가드** `restore_files(..., on_empty_keep=True)`(기본값)은 스냅샷이 **0개 파일** 을 캡처한 경우(fresh-flash: dpworldapp 미시드로 `net_dir` 가 비어 있던 baseline) 삭제 루프를 돌리지 않는다(`snapshot.py:66-92`, 특히 `86`). "아무것도 없음" 으로 되돌리면 `10-eth1.network` 가 사라져 eth1 이 lockout 되기 때문이다. 즉 빈 스냅샷이면 **현재 적용된 렌더를 그대로 두어 운영자 연결을 유지** 한다. 복원 시 파일은 per-file `tmp` → `os.replace` 로 쓰고, 스냅샷에 없던(=이번 apply 가 새로 만든) 파일은 우리 계약 파일(`RENDER_FILES`)일 때만 제거한다(`snapshot.py:80-91`). 이 cold-start 클래스는 [§10.4](#10-acceptance--security--known-limitations--수락-결과보안한계백로그) 참조. ### 7.2 Rollback — 자동/수동 복원 롤백 본문은 `_rollback(reason, claimed)` 한 곳(`apply_engine.py:424-533`)이다. 검증 실패(`_rollback_from(("VERIFYING",), "verify failed")` `apply_engine.py:387`), country apply 게이트 실패, CONFIRM_WAIT TTL 만료(`tick()` `apply_engine.py:553`), 수동 API, startup recovery 가 모두 여기로 수렴한다. **(a) 멱등 가드 + claim** 진입 즉시 현재 state 가 이미 종결 상태(`ROLLED_BACK`/`FAILED_CRITICAL`/`COMMITTED`)면 그대로 반환해 double-rollback 을 차단한다(`apply_engine.py:428-429`). race 가 가능한 경로는 `_claim()` CAS 로 승자만 본문을 실행한다(`apply_engine.py:551-553`, [§3.2(d)](#3-apply-flow--state-machine--적용-흐름상태머신)). **(b) 복원 순서 (DB-first → 파일 → 재적용 → 재검증)** 1. **스냅샷 무결성 확인**: `snapshot.verify(snap)` 실패 시 즉시 예외 → 복구 사다리로(`apply_engine.py:436-438`) 2. **DB-first 복원**: `db_fields.json` 로드 후 fmt 2 면 `present` 복원 + `absent` pop. fmt 없는 구형 스냅샷은 전체를 present 로 취급(`apply_engine.py:440-446`). 복원은 `_write_db_restore(present, absent)`(`apply_engine.py:132-140`) 3. **파일 복원**: `snapshot.restore_files(snap, net_dir)`(`apply_engine.py:459`). 빈 baseline 이면 적용본 렌더 유지 + warn 저널(`apply_engine.py:449-458`) 4. **재적용(re-apply)**: `dpworld-network-apply.service` 는 **best-effort**(`must_ok=False`) — rc≠0 이면 warn 만(`apply_engine.py:462-467`). 실제 적용 수단은 `networkctl reload`, 변경된 iface 별 `networkctl reconfigure`, wpa 변경 시 `wpa_cli reconfigure`(`apply_engine.py:468-473`) 5. **재검증(re-verify) — 실 게이트**: 검증 기준은 **복원된 persist**(`network_config.json` 에서 `intent_from_persist`)이다(`apply_engine.py:478-484`). Save-then-Apply drift flow 에서 스냅샷 DB 는 NEW 값이라 그 기준으로 재검증하면 복원된 라이브(OLD)와 영원히 불일치 → 가짜 FAILED_CRITICAL 이 나기 때문([§3.2(e)](#3-apply-flow--state-machine--적용-흐름상태머신)). persist 판독 불가 시에만 `intent_from_device(present)` 폴백. `result == "fail"` 이면 예외를 던져 FAILED_CRITICAL 사다리로(`apply_engine.py:485-486`) 6. **country 변경 특례**: country apply 의 롤백은 라디오의 실효 country 까지 확인한다. 라이브가 복원된 OLD 가 아니라 이번에 적용 시도한 NEW target 그대로면 라디오가 못 되돌아간 것 → FAILED_CRITICAL 로 에스컬레이트(`apply_engine.py:494-501`) 7. **성공 시** state = `ROLLED_BACK` 영속화(`apply_engine.py:502`). DB 는 의도적으로 user-saved 값 그대로 두어, persist 와 다르면 drift 로 남아 재시도 가능하게 하고 이를 저널 warn 으로 명시(`apply_engine.py:503-514`) **(c) 실패 시 복구 사다리 → FAILED_CRITICAL** 위 try 안의 어떤 단계든 예외가 나면 마지막 사다리로 `dpworld-net-recover.service` 를 1회 기동(60s)하고, state = `FAILED_CRITICAL` 로 마킹한 뒤 reason 을 담아 반환한다(`apply_engine.py:517-525`). **(d) 수동 롤백 API — rollback_to_lkg** `rollback_to_lkg()`(`apply_engine.py:562-579`)는 LKG 스냅샷으로 되돌린다. in-flight apply/CONFIRM_WAIT 중이면 진행 중 apply 의 `_cur` 강탈을 막기 위해 `BUSY` 반환(`apply_engine.py:566-567`). LKG 가 없으면 `INVALID`. 있으면 `apply_id = "rb-"` 의 가상 `_cur` 를 구성해 `_rollback(claimed=True)` 호출. HTTP 진입점은 `POST /api/network/rollback`([§6.1](#6-http-api-contract--http-api-계약)). **(e) startup recovery (lockout 방지 비동기)** `recover_on_startup()`(`apply_engine.py:599-648`)는 서버 시작 시 미완료 apply 를 복구한다. `_rollback` 은 subprocess(apply.service 75s + wpa 45s + recover 60s + forensic)로 느려 HTTP bind 를 분 단위로 막을 수 있으므로, **느린 롤백은 데몬 스레드로 띄우고 즉시 반환** 한다(`apply_engine.py:645-648`). 단, `VALIDATING`/`SNAPSHOT` 단계 크래시는 파일·DB 무접촉이라 변경 0 → 거짓 FAILED_CRITICAL 없이 `ABORTED` 마킹만(`apply_engine.py:627-634`). `block=True` 는 테스트용 인라인 실행(`apply_engine.py:641-644`). 전체 흐름은 [§3.6](#3-apply-flow--state-machine--적용-흐름상태머신). ### 7.3 Journal — JSONL 이벤트 저널 `Journal` 클래스(`journal.py:37-93`)는 모든 apply/rollback 이벤트를 **1줄 1 JSON** 으로 append 한다. 각 줄에는 `ts`, `uptime`(`/proc/uptime`), `boot_id`(`/proc/sys/kernel/random/boot_id`), `apply_id`, `category`, `phase`, `action`, `result`, `duration_ms`, `detail` 이 들어간다(`journal.py:46-49`). - **never-raise 계약**: `event()` 는 rotate/write 중 OSError 가 나도 절대 raise 하지 않고 `self.last_error` 에 저장 후 조용히 반환(`journal.py:44-58`). apply 흐름이 저널 I/O 때문에 죽으면 안 되기 때문. - **로테이션 5MB × 3**: 기본 `max_bytes=5MB`, `keep=3`(`journal.py:38`). 다음 줄을 쓰면 한도를 넘을 때 `.3 <- .2 <- .1 <- live` 순으로 `os.replace`(`journal.py:60-72`). - **마스킹**: `_mask()` 가 dict/list/tuple 을 재귀하며 키가 `password`/`psk`/`wifi_passwd`(`_MASK_KEYS` `journal.py:6`)이면 값을 `"***"` 로 치환(`journal.py:30-35`). 저널에 평문 비밀이 남지 않게 한다. - **tail(n)**: support bundle/진단용. live + 로테이션 파일을 newest-first 로 모아 마지막 n줄을 파싱하되, **lock 없이** 읽어 torn line 은 `json.loads` 실패 시 건너뛴다(`journal.py:74-93`). HTTP 진입점은 `GET /api/network/journal?limit=`([§6.1](#6-http-api-contract--http-api-계약)). ### 7.4 Forensic bundle — 증거 수집 `forensic_bundle()`(`journal.py:96-154`)은 **ROLLED_BACK / FAILED_CRITICAL 양쪽 모두** `_rollback` 의 `finally` 에서 수집된다(`apply_engine.py:526-533`). 포렌식 실패가 롤백 결과를 바꾸지 않도록 try/except 로 감싼다. 수집 소스(`journal.py:105-113`): | 산출 파일 | 명령 | |-----------|------| | `dmesg.txt` | `dmesg \| grep -iE 'wlan\|cnss\|pci' \| tail -200` | | `units.txt` | `journalctl -u wpa_supplicant@wlan0 -u systemd-networkd -u dpworld-network-apply -u dpworld-net-recover --since -10 min` | | `networkctl.txt` | `networkctl status` | | `wpa_status.txt` | `wpa_cli -i wlan0 status` | | `ip.txt` | `ip addr; ip route` | | `renders/*` | `render_dir` 의 설정 파일 (비밀 마스킹 후) | 특성: - **크기 한도 ≤2MB, 최근 5개 유지**: `max_bytes=2MB`, 각 add 마다 budget 차감, 0 이하면 중단(`journal.py:97, 119-127`). 오래된 `forensic_*` 번들은 mtime 기준 5개 초과분 삭제(`journal.py:149-153`). - **per-command 8s timeout + 전체 wall budget 40s**: 각 source 전에 경과시간이 `max_wall_s`(기본 40s)를 넘으면 수집을 멈추고 `budget_note.txt` 로 partial 표시(`journal.py:128-134`). 5개 외부 cmd 동기 실행이 startup recovery 를 부풀리지 않게 함. - **렌더 파일 psk 마스킹**: 번들에 담는 렌더는 세 가지 정규식으로 비밀을 제거(`journal.py:143-148`): ```python body = re.sub(r'psk="[^"]*"', 'psk="***"', body) # wpa conf 인용 psk body = re.sub(r'("(?:password|psk|wifi_passwd)"\s*:\s*)"[^"]*"', r'\1"***"', body) # persist JSON body = re.sub(r'(?m)^(\s*psk=)[0-9a-fA-F]{64}\s*$', r'\1***', body) # 64-hex psk ``` 번들 위치는 `/opt/config_backups/network/forensic_.tar.gz`([§9.4.6](#9-deployment--operations--배포운영)). ### 7.5 보안 (at-rest 권한) 스냅샷·저널·apply_state 는 wifi password / wpa psk 평문 복사본을 포함할 수 있으므로 best-effort 로 owner-only 권한을 건다. 세 파일 모두 `_restrict(path, mode)` 헬퍼를 쓰며, OSError 는 삼키고 raise 하지 않는다(Windows 에선 read-only 비트만 토글되어 효과 제한적, `snapshot.py:7-13`, `journal.py:8-14`, `apply_engine.py:18-24`). | 대상 | 권한 | 근거 | |------|------|------| | 스냅샷 디렉토리 `/` | `0o700` | `snapshot.py:25` | | 렌더 복사본(wpa conf/persist) | `0o600` | `snapshot.py:36` | | `db_fields.json`(wifi_passwd 평문) | `0o600` | `snapshot.py:41` | | `manifest.json` | `0o600` | `snapshot.py:49` | | 저널 live + 로테이션 파일 | `0o600`(매 append best-effort) | `journal.py:55, 72` | | `apply_state.json` | `0o600`(심층방어) | `apply_engine.py:68` | ## 8. UI / 사용자 인터페이스 이 섹션은 v1.6.0 Network 적용 기능의 프런트엔드(브라우저) 구성을 다룬다. 핵심은 새 페이지 **Network → Apply & Status**(`src/static/js/pages/net-apply.js`)와, 기존 **Wi-Fi** 페이지(`wifi.js`)의 §5.2 정합 변경, 그리고 **Home Dashboard** 의 Network row(`home.js`)다. 설정값 입력과 OS 적용(apply)이 의도적으로 분리되어 있다는 점([§1.4](#1-overview--motivation--features--개요동기기능))이 전체 UX 의 큰 틀이다. ### 8.1 페이지 위치 / 내비게이션 좌측 사이드바 `Network` 그룹의 4번째 leaf 로 추가된다(`src/static/index.html:73-76`). 라벨은 `Apply & Status`, `data-page="net-apply"`, 아이콘은 `plug-zap`(`app.js:118` 에서 `icon-nav-net-apply` 슬롯에 등록). 같은 그룹의 앞 3개는 입력 페이지(Wi-Fi / Ethernet / Server Setting)이고, 마지막 Apply & Status 만 적용·상태 전용이다. ``` Network ├─ Wi-Fi <- 설정 입력 ├─ Ethernet <- 설정 입력 ├─ Server Setting <- 설정 입력 └─ Apply & Status <- 미리보기 -> 적용 -> 확인 (net-apply.js) ``` 다른 입력 leaf 들과 달리 `nav-net-apply` 에는 `nav-item__dirty`(미저장 표시 점) 마크업이 없다 — 이 페이지는 설정을 입력하는 곳이 아니라 적용하는 곳이기 때문이다. 같은 이유로 페이지 내 모든 컨트롤은 `data-no-dirty` 가 붙어 page-dirty / nav-guard 모달을 트리거하지 않는다(아래 §8.2.8). 페이지는 page object 패턴(`render`/`mount`/`destroy`/`validate`)을 따르며 `app.js:85` 에서 `netApplyPage` 로 라우팅 테이블에 등록된다. ### 8.2 Apply & Status 페이지 구성 `netApplyPage.render()`(`net-apply.js:243-268`)가 정적 골격을 그리고, `mount()` 가 이벤트 바인딩 + 첫 데이터 로드 + 폴링을 건다. 페이지 헤더 설명이 분리 원칙을 명시한다: "저장(Save)된 네트워크 설정을 OS 에 적용하고 상태를 감시합니다. 설정 입력은 Wi-Fi/Ethernet/Server Setting 페이지에서." 상단 액션 바에는 `변경 미리보기`(dry-run)와 `LKG 롤백` 두 버튼이 있다. 연결되는 API: `/api/network/state`, `/api/network/apply`, `/api/network/apply/status`, `/api/network/apply/confirm`, `/api/network/rollback`, `/api/network/journal`, `/api/network/config`(`net-apply.js:1-3`, `83`, `94`, 계약은 [§6](#6-http-api-contract--http-api-계약)). #### 8.2.1 drift / country 배너 (`#net-state`) `renderState(s)`(`net-apply.js:23-46`)가 `/api/network/state` 응답으로 두 종류의 경고 배너를 조건부로 그린다. | 조건 | 배너 | 의미 | |---|---|---| | `s.drift.dirty` | `alert-triangle` warning | DB ≠ 적용본. `drift.fields.length` 개 필드 미적용 + 필드명 mono 나열. "미적용 변경은 다음 재부팅 시 dpworldapp 이 적용" | | `s.country_pending` | `refresh-cw` warning | Country 변경 보류 중 — **Reboot required**(§6.2 권고) | #### 8.2.2 Live Interfaces 카드 `s.interfaces` 의 각 항목을 `이름 → 상태 + 주소` row 로 렌더(`net-apply.js:27-29, 37`). 비어 있으면 `—`. OS 라이브 인터페이스 상태를 보여준다. #### 8.2.3 Watchdog 상태 카드 `s.watchdog` 에서 두 row(`net-apply.js:38-42`): - **상태**: `wd.critical` 이면 `CRITICAL`(fail 뱃지), 아니면 `wd.enabled` 시 `enabled`(ok), 그 외 `off`(na). - **최근 판정**: `wd.last_results` 를 mono JSON 으로 표시(watchdog 내부는 [§5](#5-watchdog--self-healing--상시-감시자가복구)). #### 8.2.4 마지막 Apply 카드 + CONFIRM_WAIT Confirm 버튼 `lastApplyCard(la)`(`net-apply.js:48-56`)가 `apply_id` 와 `state`(기본 `IDLE`)를 보여준다. `la.state === 'CONFIRM_WAIT'` 이면 경고 배너에 남은 초(`confirm_remaining_s`)와 함께 **Confirm** 버튼을 그린다. 이 버튼은 `data-net-confirm=""` 속성을 갖고, 클릭은 직접 바인딩이 아니라 **위임(delegation)** 으로 처리된다. 위임이 중요한 이유: eth1(웹 접속 경로) 변경 시 사용자는 *새 IP 로 재접속* 하여 페이지를 다시 로드해야 한다(`net-apply.js:50-51`, [§3.5](#3-apply-flow--state-machine--적용-흐름상태머신)). re-render/재접속으로 DOM 이 갈아끼워져도 Confirm 이 동작하도록, `mount()` 에서 컨테이너에 단일 위임 리스너 `onDelegatedClick`(`net-apply.js:233-236`)을 건다. SPA 가 페이지 전환 시 `#page-container` 의 `innerHTML` 만 교체하고 엘리먼트는 재사용하므로, 방문마다 리스너가 누적되어 Confirm 이 N중 발화하는 것을 막기 위해 `boundContainer` 를 추적해 이전 리스너를 제거 후 재등록하고(`net-apply.js:277-279`), `destroy()` 에서도 제거한다(`net-apply.js:300-303`). #### 8.2.5 dry-run diff 모달 (`#net-diff-modal`) `변경 미리보기` 클릭 → `doDryRun()`(`net-apply.js:111-150`)이 `POST /api/network/apply {dry_run:true, fields:{}}` 를 호출한다. `fields:{}` 는 "현 DB 기준 drift 를 적용 대상으로 삼으라" 는 의미. 응답의 `diff` 를 `필드 / 적용본 / DB(새값)` 3열 테이블로, `warnings` 를 뱃지로 모달에 채운다. diff 가 비면 "변경 없음" 문구. 모달은 `nav-guard__backdrop` 구조를 재사용한다(`net-apply.js:256`). 모달 내 게이트 컨트롤(`net-apply.js:259-263`)은 서버 warning 에 따라 조건부 노출/리셋된다. | 컨트롤 | id | 표시 조건 | 효과 | |---|---|---|---| | eth1 동의 체크박스 | `net-eth1-consent` | `warnings` 에 `eth1_confirm` 포함 시 행 노출(`:132`) | **미체크면 `적용 시작` 비활성**(게이트, `:141-147`) | | country 지금 적용 | `net-country-now` | `warnings` 에 `country_reboot_deferred` 포함 시(`:133`) | 체크 시 `country_now:true` 로 전송. v1.11.6 부터 live 모듈 리로드가 제거되어 실효 변경은 재부팅 시점이다(§3.3) — 이 옵션은 호환용 플래그로 남아 있다 | | 검증 생략(force) | `net-force` | 항상 | 체크 시 `force:true` — 사전 프로비저닝용, 저널 기록([§3.4](#3-apply-flow--state-machine--적용-흐름상태머신)) | 세 체크박스는 **모달이 열릴 때마다 false 로 리셋** 된다(`net-apply.js:138-140`) — 동의가 dry-run 간에 잔존하면 안 되기 때문. `gate()` 가 `diff.length > 0 && errors.length === 0 && consentOk` 를 만족할 때만 `net-apply-go` 를 활성화한다(`net-apply.js:141-147`). 세 체크박스와 동의 라벨, `적용 시작`/`닫기` 모두 `data-no-dirty`. #### 8.2.6 적용 실행 + 1s 진행 폴링 (epoch 가드) `적용 시작` → `doApply()`(`net-apply.js:152-164`)가 `POST /api/network/apply {dry_run:false, force, country_now, fields:{}}` 를 보내고 비동기 `apply_id` 를 받은 뒤 `pollStatus()` 를 시작한다. 적용은 즉시 끝나지 않고 status 폴링으로 진행을 추적한다. `pollStatus()`(`net-apply.js:167-213`)는 `setInterval` 이 아니라 **체이닝된 `setTimeout(tick, 1000)`**(1초 간격)으로 동작하며 **epoch 가드** 를 둔다: - 모듈 전역 `pollEpoch` 를 매 호출마다 증가시키고, 각 `tick()` 은 자기 `epoch` 가 현재 `pollEpoch` 와 일치할 때만 결과를 반영/재예약한다(`net-apply.js:169, 176, 208`). 새 apply 가 시작되면(`doApply` 의 `pollEpoch++`, `:160`) 이전 세대의 in-flight tick 은 stale 로 스스로 폐기된다. - 진행 카드(`#net-progress`)는 `state|apply_id|steps.length` 키가 바뀔 때만 `innerHTML` 을 다시 그린다(`lastProgressKey`, `:179-191`). `CONFIRM_WAIT` 일 때는 카운트다운 텍스트만 `[data-net-count]` 에 갱신해 깜빡임/리스너 손실을 막는다(`:192-196`). - 각 step 은 결과별 글리프(`ok`→체크, `warn`→삼각, 그 외→엑스)로 표시(`:182-185`). 진행 중 `CONFIRM_WAIT` 면 진행 카드 안에도 동일한 위임형 Confirm 버튼을 그린다(`:186-190`). - 종료 상태 집합 `TERMINAL = [COMMITTED, ROLLED_BACK, FAILED_CRITICAL, FAILED_VALIDATION, NOOP]` 에 도달하면 폴링을 멈추고 토스트 + `refreshState()` 후 재예약하지 않는다(`:198-205`). - 폴링 중 fetch 실패는 조용히 무시한다 — eth1 IP 변경 도중 연결 단절이 정상적으로 발생할 수 있기 때문(`:206`). 페이지 재진입/새로고침 시 `mount()` 가 status 를 한 번 조회해 `IN_FLIGHT` 상태(`VALIDATING/SNAPSHOT/WRITING/APPLYING/VERIFYING/CONFIRM_WAIT`)면 폴링을 자동 재개한다(`net-apply.js:281-291`). 별도 10초 주기 `refreshState` 타이머(`#net-state`/watchdog/journal 갱신)도 돈다(`:292`). #### 8.2.7 Journal 뷰어 (`#net-journal`) `refreshState()` 가 `GET /api/network/journal?limit=50` 결과를 `ts [category/phase] action → result` 라인으로 `
` 에 출력한다(`net-apply.js:104-108`). 최근 50건. 저널 내부 포맷은 [§7.3](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식).

#### 8.2.8 Watchdog 제어 카드 (`#net-watchdog-control`)

`renderWatchdogControl(cfg)`(`net-apply.js:59-79`)가 `/api/network/config` 의 `net_config` 로 운영자용 watchdog 제어를 그린다:
- **kill-switch 토글** `net-wd-enabled`: `watchdog_enabled`(`on`/`off`) — 끄면 자율 watchdog 정지.
- **자동 복구 토글** `net-wd-auto`: `watchdog_auto_recover` — 끄면 이상 감지 시 WARN 로그만.
- **복구 재무장 버튼** `net-wd-reset`: `reset_watchdog_critical:true` 전송(critical reset).

토글/버튼은 `postWatchdogConfig()`(`net-apply.js:81-90`)로 `POST /api/network/config` 한 뒤 응답의 `net_config` 로 카드를 다시 그리고 `refreshState()` 한다. 실패 시 토스트 + `refreshWatchdogControl()` 로 서버 기준 재동기화(낙관적 UI 되돌림). 셋 다 `data-no-dirty`.

모든 페이지 내 컨트롤에 `data-no-dirty` 를 붙이는 이유: dirty-tracker 는 capture-phase input/change 에서 `target.closest('[data-no-dirty]')` 가 잡히면 `markDirty` 를 건너뛴다(`dirty-tracker.js:23, 34-35`). 적용·watchdog 제어는 "설정 변경" 이 아니라 즉시 동작/일시 UI 이므로 page-dirty 와 nav-guard 모달을 트리거하면 안 된다.

### 8.3 Wi-Fi 페이지 §5.2 정합 (`wifi.js`)

Apply 기능과 연동되는 dpworldapp 한계를 입력 단계에서 미러링하도록 Wi-Fi 페이지가 v1.6.0 §5.2 로 조정됐다(검증 규칙 원본은 [§4.5](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더)).

- **Security 옵션 축소**: 드롭다운은 `WPA/WPA2`(값 `wpa/wpa2`)와 `Open (none)`(값 `none`) 둘만 제공한다(`wifi.js:173-176`). **WEP 제거**.
- **legacy tolerant read**: 저장된 값이 `none`/`Open`/`open` 이면 Open 으로, `WEP` 이면 WPA/WPA2 가 선택되고 "WEP 미지원 — 저장 시 WPA/WPA2 로 전환" 힌트를 띄운다(`wifi.js:159-162, 177`). 새 행 기본은 `wpa/wpa2`(`:143, 410`).
- **19바이트 제한**: SSID/비밀번호 입력 모두 `maxlength="19"`(`wifi.js:165, 168`). `validate()`(`:460-475`)는 UTF-8 바이트 길이(`TextEncoder`) 기준으로 SSID ≤19바이트, `"`·`\` 금지, `none` 은 빈 비밀번호, 그 외는 8-19바이트 검증.
- **DNS '저장만 됨' 라벨**: DNS 1/DNS 2 라벨에 `(저장만 됨 — 현 펌웨어 미적용)` 힌트(`wifi.js:322, 328`). 즉 입력·저장은 되지만 현재 펌웨어는 적용하지 않음을 명시(dead 필드, [§4.1](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더) #5/#6).
- **Country Code**: 별도 Region 카드에 `Frequently used` + 5개 대륙 그룹(205코드) 드롭다운. 미인식/공란/stale 값은 기본 `AE` 로 폴백하며 dpworldapp 이 Wi-Fi 모듈에 적용(`wifi.js:26-45, 336-347`).

### 8.4 Home Dashboard — Network row

`renderNetwork()`(`home.js:568-601`)의 Network 카드 맨 아래에 `networkApplyRows(status.network_apply)` 한 줄이 추가된다(`home.js:599`). `networkApplyRows(na)`(`home.js:752-766`)는 `system_status.network_apply` 요약([§2.3 (c)](#2-architecture--module-map--아키텍처모듈-지도))을 뱃지로 압축 표시한다. provider 미주입/구버전이거나 `na.error` 면 아무것도 그리지 않는다(`:753-754`).

| 뱃지 | 출처 | 표시 |
|---|---|---|
| 인터페이스 OK/FAIL | `na.interfaces`(이름→tier) | tier `ok` 면 ok 뱃지, 아니면 fail 뱃지(`:755-756`) |
| drift | `na.drift.dirty` | `drift <필드수>`(warn)(`:757-758`) |
| watchdog | `na.watchdog` | `critical` → `watchdog CRITICAL`(fail), `enabled` 아니면 `watchdog off`(na), enabled 면 무표시(`:759-761`) |
| country | `na.country_pending` | `country: reboot 필요`(warn)(`:762-763`) |

같은 Network 카드의 인터페이스별 IP/MAC 에는 OS vs dpworldapp **drift indicator** 가 별도로 붙는다(`home.js:570-584`, MAC 은 `_normalizeMac` 정규화 후 비교, `:747-749`). 대시보드는 한눈에 적용 건전성을 보고, 상세/조치는 Apply & Status 페이지로 가는 구조다.

### 8.5 운영자 관점 흐름 (end-to-end)

```
[Wi-Fi / Ethernet / Server Setting 페이지]
        |  값 입력 -> Save (DB 저장, dirty 표시)
        v
[Network -> Apply & Status]
   1) drift 배너로 "DB != 적용본" 확인
   2) 변경 미리보기(dry-run) -> diff 모달
        - eth1 변경이면 동의 체크박스 필수(게이트)
        - country 변경이면 'country 지금 적용' 선택 가능
   3) 적용 시작 -> 1s 진행 폴링(steps 체크/삼각/엑스)
   4) (eth1 변경 시) CONFIRM_WAIT
        - 새 IP 로 재접속: http://<새 IP>:9090
        - 같은 페이지에서 Confirm (미확정 시 자동 롤백)
        v
   COMMITTED / ROLLED_BACK / FAILED_* / NOOP
```

핵심 원칙: **입력(Save)과 적용(Apply)이 분리** 되어 있어, 잘못 저장된 값이 곧바로 OS 에 반영되지 않는다. eth1(웹 접속 경로) 변경은 자기 자신을 끊을 수 있으므로 동의 게이트 + 새 IP 재접속 후 Confirm + 미확정 자동 롤백이라는 안전장치를 둔다([§3.5](#3-apply-flow--state-machine--적용-흐름상태머신)). 문제가 생기면 상단 `LKG 롤백` 버튼으로 last-known-good 설정으로 되돌릴 수 있다(`doRollback()`, `net-apply.js:225-230`, confirm 다이얼로그 포함, [§7.2(d)](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식)).

## 9. Deployment & Operations / 배포·운영

이 섹션은 Web Configurator(특히 v1.6.0 네트워크 적용 엔진)를 운영 장비에 배포하고, 운영 중 안전하게 다루기 위한 systemd 유닛 구성, 배포 스크립트의 네트워크 단계, 경로/포트 사실관계, 운영 런북, 보안 운영 완화를 다룬다. 대상 장비는 운영기 `192.168.55.56`(telechips-tcc8030-main), 검증기 `192.168.55.54` 이다.

### 9.1 systemd 유닛 구성

세 개의 유닛이 협력한다. 모두 영속 overlay 인 `/lib/systemd/system/` 에 설치된다(`/etc/systemd/system/` 은 이 보드에서 재부팅 시 소실되는 tmpfs overlay 이기 때문 — `deploy.ps1:166-172`, `:201` 주석).

#### 9.1.1 `web-configurator.service` (메인 HTTP 서비스)

근거: `deploy/web-configurator.service`.

```ini
[Unit]
After=network.target wpa_supplicant@wlan0.service
Wants=network.target wpa_supplicant@wlan0.service
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
ExecStart=/usr/bin/python3 /usr/lib/web-configurator/src/server.py
Environment=DB_PATH=/home/root/db/dynamic_data.db
Environment=LOG_DIR=/opt/log/dpworldapp
Environment=PORT=9090
MemoryMax=48M
Restart=always
RestartSec=5
```

핵심 운영 포인트:

| 항목 | 값/설정 | 이유 (파일:라인) |
|------|---------|------------------|
| `After=`/`Wants=` | `wpa_supplicant@wlan0.service` | watchdog 첫 틱에서 `/var/run/wpa_supplicant` 제어 소켓이 보이도록 기동 순서 보장 — 부팅 race 로 인한 wpa 쿼리 실패 → production WiFi flap 방지([§5.2](#5-watchdog--self-healing--상시-감시자가복구), `web-configurator.service:8-11`) |
| `PrivateTmp=no` | 공유 `/tmp` 사용 | `wpa_cli` 가 응답 수신용 클라이언트 소켓을 `/tmp/wpa_ctrl_` 에 bind 하는데(컴파일 고정), 사설 tmpfs 는 호스트의 `wpa_supplicant` 가 못 봐서 영구 무응답이 됨. 따라서 공유 `/tmp` 로 전환하고 `ReadWritePaths` 에 `/tmp` 를 명시. v1.4.6.6 의 log-download `/tmp` staging 도 같은 라인이 유지(`web-configurator.service:31-39`) |
| `ProtectSystem=strict` + `ReadWritePaths` | 화이트리스트 | `strict` 는 `/tmp` 까지 RO 로 만들므로 쓰기 경로를 명시해야 함. 화이트리스트: `/home/root/db`, `/opt/log/dpworldapp`, `/opt/fw_staging`, `-/opt/config_backups`, `-/home/root/network`, `/tmp`(`:39`) |
| wpa 제어 소켓 RW | `-/var/run/wpa_supplicant -/run/wpa_supplicant` | `/var/run` 은 `/run` 의 symlink — namespace 는 실경로 기준이므로 둘 다 지정(`:40-41`) |
| `MemoryMax=48M` | 메모리 캡 | v1.1.1 안정화 도입. dpworldapp 등 펌웨어 프로세스와의 공존 — amss.bin/펌웨어 MD5 read 시 1MB 청크 강제 등이 이 캡 전제([§5.5](#5-watchdog--self-healing--상시-감시자가복구), `:24`) |
| 추가 샌드박싱 | `NoNewPrivileges`, `ProtectHome=read-only`, `PrivateDevices`, `ProtectKernelTunables/Modules/ControlGroups`, `RestrictSUIDSGID`, `LockPersonality` | OS 레벨 최소비용 sandboxing(`:42-46`) |

`ProtectKernelModules=yes` 때문에 메인 서비스는 `modprobe` 를 직접 호출할 수 없다 — 이를 별도 recover 유닛에 위임한다(아래 9.1.2).

#### 9.1.2 `dpworld-net-recover.service` (wlan 모듈 복구)

근거: `deploy/dpworld-net-recover.service`.

```ini
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'modprobe wlan_cnss_core_pcie; modprobe wlan; systemctl try-restart wpa_supplicant@wlan0.service'
```

- 목적: `apply.sh` 가 다루지 못하는 "wlan 모듈 완전 다운" 사각지대 복구.
- 메인 서비스 샌드박스의 `ProtectKernelModules=yes` 를 유지하면서 `modprobe` 만 수행하도록 분리 — 웹/watchdog 은 `systemctl start dpworld-net-recover.service` 로만 호출한다(`dpworld-net-recover.service:3-5`).
- `modprobe -r`(모듈 제거)는 의도적으로 미포함 — 과거 `ExecStopPost=modprobe -r` 사고 재발 방지(`:5`).
- oneshot 이라 `enable` 불필요(파일만 설치). watchdog 복구 사다리(`wlan_module`)와 롤백 마지막 사다리에서 호출된다([§5.3](#5-watchdog--self-healing--상시-감시자가복구) `watchdog.py:18`, [§7.2(c)](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식) `apply_engine.py:520`).

#### 9.1.3 `dpworld-network-apply-ondemand.conf` (drop-in)

근거: `deploy/dpworld-network-apply-ondemand.conf`. 설치 경로 `/lib/systemd/system/dpworld-network-apply.service.d/10-ondemand.conf`.

```ini
[Unit]
Requires=
```

- 문제: 펌웨어 소유 oneshot `dpworld-network-apply.service` 가 `Requires=dpworld-network-seed.service` 를 가지는데, `seed` 는 boot-only oneshot 이라 재부팅 후 웹이 `systemctl start` 로 온디맨드 호출하면 `seed` 가 inactive → "Dependency failed" rc=1 이 됨(.56 실측).
- 해소: 런타임 `Requires=` 를 빈 값으로 override. `apply.sh` 자체는 자족적이므로 무해. `After=` 는 비우지 않아 부팅 순서는 유지(`dpworld-network-apply-ondemand.conf:1-7`).
- 참고: `apply.service` 호출은 어차피 best-effort 이고(`must_ok=False`), 실제 적용 게이트는 `networkctl reload/reconfigure` + verify 다(`apply_engine.py:347-356`, [§3.1](#3-apply-flow--state-machine--적용-흐름상태머신)).

### 9.2 `deploy.ps1` 네트워크 단계

근거: `scripts/deploy.ps1`. 고정 사실: `$AppDir=/usr/lib/web-configurator`, `$Service=web-configurator`, `$Port=9090`, SSH 는 `root@` + `StrictHostKeyChecking=no -o BatchMode=yes`(`deploy.ps1:31-35`).

배포 순서의 핵심 설계는 **유닛/디렉토리 설치를 src swap 전에 수행** 하는 것이다 — scp 실패 시 OLD src 가 디스크에 살아있는 상태로 중단되어 롤백 창을 보호한다(`deploy.ps1:173-174`).

1. 디렉토리 사전 생성(`deploy.ps1:146`, `:198`):
   - `mkdir -p $AppDir /opt/fw_staging /opt/config_backups` — `ProtectSystem=strict` 가 존재+화이트리스트를 동시에 요구하므로 미리 만든다.
   - `mkdir -p /home/root/network /opt/config_backups/network && chmod 700 /opt/config_backups/network` — 후자는 plaintext PSK 가 담기는 백업 디렉토리라 world-read 차단(700). 전자는 dpworldapp 공유 디렉토리라 기본 권한 유지(`:196-198`).
2. `web-configurator.service` 설치(`:175-193`): 첫 배포면 scp 후 `daemon-reload && enable`. 이후엔 `sha256sum`(원격) vs `Get-FileHash`(로컬) **hash-compare** — 변경 시에만 재설치 + `daemon-reload`.
3. `dpworld-net-recover.service` 설치(`:204-222`): 동일 hash-compare 패턴. oneshot 이라 enable 없이 파일만. 구버전이 만든 휘발성 `/etc/systemd/system/` 사본은 `rm -f` 로 제거(systemd 우선순위가 `/etc` 를 먼저 보기 때문).
4. `10-ondemand.conf` drop-in 설치(`:229-248`): `mkdir -p .../dpworld-network-apply.service.d` 후 hash-compare scp + `daemon-reload`. 디렉토리도 영속 `/lib` 하에 둔다.
5. swap & 검증(`:250-291`): `rm -rf src && mv _deploy_tmp/src src` → 버전 스탬프(`DEPLOYED_VERSION`, `deploy-history.log`) → `systemctl restart` + `Confirm-Health`. health 실패 시 **자동 롤백**(`backups/src-$ts` 복원 + 재시작, `DEPLOYED_VERSION` 을 `version=rolled-back` 으로 갱신). src 백업은 최근 3개 유지(`:155-164`).

검증: `ssh root@ cat /usr/lib/web-configurator/DEPLOYED_VERSION`(`:300`).

### 9.3 경로 / 포트 (운영 .56 기준)

| 항목 | 값 | 출처 (env / 기본값) |
|------|-----|---------------------|
| HTTP 포트 | `9090` | `PORT` env(유닛에서 9090 지정; 코드 기본도 9090) — `server.py:86`, `web-configurator.service:28`. 디바이스의 `:8080` 은 별개의 레거시 Java app-runner |
| 바인드 주소 | `0.0.0.0`(기본) | `HOST` env(`server.py:78`) — 보안 완화는 §9.5 참조 |
| 설정 DB | `/home/root/db/dynamic_data.db` | `DB_PATH` env(`server.py:92`) |
| 로그 디렉토리 | `/opt/log/dpworldapp` | `LOG_DIR` env(`server.py:154`) |
| 네트워크 렌더 디렉토리 | `/home/root/network` | `NET_DIR` env, 기본 `network_config.json` 등 렌더 산출물(`server.py:132,157,164`) |
| 네트워크 백업/스냅샷 | `/opt/config_backups/network` | `NET_BACKUPS_DIR` env(`server.py:158`) |
| apply 상태 파일 | `/opt/config_backups/network/apply_state.json` | `NET_STATE_PATH` env(`server.py:159-160`) |
| 네트워크 저널 | `/opt/log/dpworldapp/network_journal.jsonl` | `LOG_DIR + network_journal.jsonl`(`server.py:154-155`) |
| 포렌식 번들 | `/opt/config_backups/network/forensic_.tar.gz` | `backups_dir` 하(`apply_engine.py:530`, `journal.py:114`) |
| firmware staging / 백업 | `/opt/fw_staging`, `/opt/config_backups` | `FW_STAGING_DIR`, `FW_BACKUPS_DIR` env(`server.py:104-105`) |

네트워크 서브시스템은 import 시 실패해도 서버가 죽지 않도록 try/except 로 감싸져 있고, 실패 시 `/api/network/*` 는 503 을 반환한다(`server.py:166-168`, `:684-687`, [§2.3](#2-architecture--module-map--아키텍처모듈-지도)·[§6.5](#6-http-api-contract--http-api-계약)). Windows dev box 안전을 위한 fail-soft 다.

### 9.4 운영 런북

#### 9.4.1 네트워크 설정 적용 절차

비동기 상태머신이다. apply 는 `apply_id` 와 함께 `STARTED` 를 즉시 반환하고, 결과는 status 폴링으로 확인한다(eth1 IP 변경 시 networkctl 이후엔 구 연결로 동기 응답이 불가하기 때문 — `net_routes.py:44-50`, `apply_engine.py:252-260`).

1. (선택) **Dry-run** — `POST /api/network/apply` body `{"fields":{...},"dry_run":true}`. diff/errors/warnings 만 계산(라이브 무접촉). 경고 예: `eth1_confirm`, `wifi_disrupt`, `country_reboot_deferred`, `dns_saved_only`(`apply_engine.py:206-225`).
2. **Apply** — `POST /api/network/apply` body `{"fields":{...}}`. `fields` 는 `netmodel.NETWORK_DEV_KEYS` 화이트리스트만 허용(미지 키는 400 — 스냅샷이 못 잡는 side-door 차단, `net_routes.py:35-40`, [§6.3](#6-http-api-contract--http-api-계약)).
3. **Status 폴링** — `GET /api/network/apply/status?id=`. 폴링이 confirm TTL 타이머의 안전망 역할을 겸한다(`engine.tick()`, `net_routes.py:52-54`). state 진행: `VALIDATING → SNAPSHOT → WRITING → APPLYING → VERIFYING → (CONFIRM_WAIT) → COMMITTED`. 실패 분기: `FAILED_VALIDATION`, `ABORTED`(스냅샷 전), `ROLLED_BACK`, `FAILED_CRITICAL`([§3.7](#3-apply-flow--state-machine--적용-흐름상태머신)).
4. **상태 종합** — `GET /api/network/state`(interfaces live + drift + watchdog snapshot + last_apply + country_pending, `net_routes.py:71-76`).

쓰기는 DB-first(§4.1)다: 스냅샷 채취 → DB partial-merge → 렌더 파일(`network_config.json` 등) 작성 → `systemctl start dpworld-network-apply.service`(best-effort) → `networkctl reload` + 변경 iface `reconfigure` → 필요 시 `wpa_cli reconfigure` → verify(`apply_engine.py:326-384`).

#### 9.4.2 eth1 변경 시 주의 — confirm 필수 (자기 차단 방지)

eth1(운영자 접속 인터페이스로 가정)이 변경 대상에 포함되면 verify 후 `COMMITTED` 로 바로 가지 않고 **`CONFIRM_WAIT`** 로 진입하며 TTL 90초 타이머가 무장된다(`CONFIRM_TTL_S=90`, `apply_engine.py:7`, `:391-398`).

- 운영자는 **새 IP 로 재접속** 해 `POST /api/network/apply/confirm` body `{"apply_id":""}` 로 확정해야 한다(`net_routes.py:56-61`, `apply_engine.py:535-540`).
- 90초 내 confirm 이 없으면 엔진 타이머/폴링/watchdog 중 하나가 TTL 만료를 감지해 **자동 롤백** 한다 — 잘못된 IP 로 운영자가 영구 lockout 되는 것을 막는 안전장치(`apply_engine.py:542-553`, [§3.5](#3-apply-flow--state-machine--적용-흐름상태머신)).
- `country_code` 변경은 별도 정책: 단독 변경이면서 `country_now` 미동의면 `COMMITTED + country_pending`(재부팅 필요)로 deferred. 다른 필드와 혼합된 country 실효 변경은 v1.9 split-apply 로 분리(비-country 즉시 + country deferred)된다. **v1.11.6 부터 country 는 `country_now` 동의 여부와 무관하게 live `modprobe -r` 없이 항상 재부팅 시점에 반영**된다(`apply_engine.py:185-203`, `:336-344`, [§3.3](#3-apply-flow--state-machine--적용-흐름상태머신), [firmware-boot-hardening.md](firmware-boot-hardening.md)).

#### 9.4.3 롤백

- **자동**: verify 실패(non-force) 또는 confirm TTL 만료, 또는 startup recovery 시 미완료 apply 발견 시. 스냅샷에서 DB(present 복원 + absent pop) + 렌더 파일 복원 후 재적용·재검증. 재검증 실패 시 `dpworld-net-recover.service` 1회 호출 후에도 안 되면 `FAILED_CRITICAL`(`apply_engine.py:424-525`, [§7.2](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식)).
- **수동(LKG)**: `POST /api/network/rollback` → last-known-good 스냅샷으로 복원. in-flight apply/CONFIRM_WAIT 중엔 409 BUSY, LKG 없으면 409 INVALID(`net_routes.py:63-69`, `apply_engine.py:562-579`).

#### 9.4.4 Watchdog 끄기 (kill-switch)

상시 감시·자가복구 데몬(틱 기본 30초, 튜너블 10–300초, 히스테리시스 2, cooldown 5분, 시간당 4회 한도)이 `net_config` DB 키를 읽어 동작한다(`watchdog.py:1-6`, `:64-77`, [§5.6](#5-watchdog--self-healing--상시-감시자가복구)).

- 현재 설정 조회: `GET /api/network/config`(기본값 merge — `watchdog_enabled=on`, `watchdog_auto_recover=on`, `watchdog_interval_s=30`, `net_routes.py:96-97`, `:22-23`).
- **kill-switch**: `POST /api/network/config` body `{"watchdog_enabled":"off"}` 또는 자동복구만 끄려면 `{"watchdog_auto_recover":"off"}`(감지 WARN 은 계속, 복구 액션만 중단). 엄격 화이트리스트 — 미지 키/이상치는 400(`net_routes.py:99-137`).
- 틱 주기 변경: `{"watchdog_interval_s":<10..300 정수>}`(float/bool 거절, `net_routes.py:118-126`).
- **CRITICAL 재무장**: 시간당 4회 한도 도달 시 watchdog 가 스스로 `_critical` 잠금(자멸 방지). 1시간 창이 비면 자동 재무장되지만, 즉시 되살리려면 `POST /api/network/config` body `{"reset_watchdog_critical":true}`(단독 전송 필수, `net_routes.py:104-111`, `watchdog.py:197-203`).

주의: gateway ping 은 2틱마다 WARN 로그만 남기고 **자동복구를 하지 않는다**(auth-loop 이력 교훈, `watchdog.py:168-177`). 케이블 미연결/링크 DOWN 인 eth0/eth1 도 복구 보류(시간당 한도로 인한 watchdog 자멸 방지, `watchdog.py:131-154`).

#### 9.4.5 Soak / 운영 확인 명령

- 서비스 상태: `ssh root@192.168.55.56 systemctl status web-configurator`.
- 헬스/버전: `curl http://192.168.55.56:9090/api/system-status`(network_apply provider 가 watchdog/drift/interfaces 를 fail-soft 로 포함, `server.py:177-190`), 그리고 `cat /usr/lib/web-configurator/DEPLOYED_VERSION`.
- 네트워크 종합/drift: `curl 'http://192.168.55.56:9090/api/network/state'`.
- 저널 tail(이벤트 추적, soak 관찰): `curl 'http://192.168.55.56:9090/api/network/journal?limit=50'`(최대 500, psk/password 마스킹됨, `net_routes.py:78-83`, `journal.py:6,30-35`). 파일 직접: `/opt/log/dpworldapp/network_journal.jsonl`(+ `.1/.2/.3` 로테이션, 5MB×3).
- watchdog heartbeat 는 정상 시 1시간마다만 디스크 기록(정상 틱은 RAM 카운터) — soak 중 heartbeat 라인 존재로 생존 확인(`watchdog.py:269-272`).

#### 9.4.6 장애 시 포렌식 번들 위치

apply 가 `ROLLED_BACK` 또는 `FAILED_CRITICAL` 로 끝날 때마다 자동으로 증거 tar.gz 가 수집된다(`apply_engine.py:526-533`, [§7.4](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식)).

- 위치: `/opt/config_backups/network/forensic_.tar.gz`(개당 ≤2MB, 최근 5개 유지, `journal.py:96-99`, `:149-154`).
- 내용: `dmesg`(wlan/cnss/pci grep), `journalctl`(wpa_supplicant/networkd/apply/recover 최근 10분), `networkctl status`, `wpa_cli status`, `ip addr; ip route`, 그리고 `renders/` 의 렌더 파일들(psk/password/64-hex psk 마스킹). per-cmd 8초 + 전체 40초 wall budget 으로 startup recovery 를 부풀리지 않게 제한(`journal.py:104-148`).

### 9.5 보안 운영 완화 (C1: 관리 IF 바인딩 + 방화벽 allow-list)

현재 코드 기본값은 `HOST=0.0.0.0`(`server.py:78`)이며 인증이 없다 — LAN 의 누구나 `/api/network/*`, firmware OTA 등 모든 엔드포인트에 도달할 수 있다(보안 자세 평가는 [§10.3](#10-acceptance--security--known-limitations--수락-결과보안한계백로그)). 코드 변경 없이 적용 가능한 운영 완화:

1. **관리 인터페이스로만 바인딩**: 메인 서비스 유닛에 `Environment=HOST=<관리망 IP>` 를 추가(예: 운영자 접속용 eth1 의 고정 IP). `server.py:78,1115` 가 `(HOST, PORT)` 로 bind 하므로 외부/필드망 인터페이스에서는 9090 이 열리지 않는다. 단, eth1 IP 를 네트워크 적용으로 바꾸는 경우 바인딩 주소와 충돌하지 않도록 주의(§9.4.2 confirm 절차와 병행).
2. **방화벽 allow-list**: 9090(및 nginx 가 앞단이면 80/443)에 대해 운영자 워크스테이션 서브넷만 허용하는 ingress 규칙을 둔다. iptables 예시:
   ```sh
   iptables -A INPUT -p tcp --dport 9090 -s <운영자_서브넷>/24 -j ACCEPT
   iptables -A INPUT -p tcp --dport 9090 -j DROP
   ```
3. 두 완화는 보완적이다 — 바인딩은 인터페이스 노출면을 줄이고, allow-list 는 동일 관리망 내 비인가 호스트를 차단한다. 근본적 엔드포인트 인증(로그인 기능)은 별도 SEC-2 트랙으로 분리되어 있으므로([§10.3](#10-acceptance--security--known-limitations--수락-결과보안한계백로그)), 그 전까지는 위 망/방화벽 레벨 완화를 운영 표준으로 적용한다.

## 10. Acceptance · Security · Known Limitations / 수락 결과·보안·한계·백로그

이 섹션은 v1.6.0 Network Apply Engine 의 실기기 수락 결과, 출시 전 다단 리뷰 과정, 미인증 API 의 보안 자세(security posture), 그리고 dpworldapp 협의 대기 항목을 포함한 알려진 한계와 백로그를 정리한다. 근거: 수락 시나리오·soak 검증 기록(내부, 이 배포물에 미포함), `CHANGELOG.md` `[v1.6.0]`.

### 10.1 `.56` 실기기 수락 — 10/10 + A-0 PASS

수락은 운영기 `.56` 에서 2026-06-12 ~ 06-13 사이 수행됐다. SSH fallback 경로로 wlan0(10.227.231.38)를 확보해 eth1 단절 시나리오에서도 Wi-Fi 독립 접속을 유지했다. 사용자 결정 ① `lte_server_ip = 104.208.105.62` 유지(A-0 drift 정정 후 확정), 결정 ② 나머지 시나리오 전체 자율 진행 승인.

| 시나리오 | 내용 | 결과 | 핵심 수치 / 증거 |
|---|---|---|---|
| A-0 | drift 정정 — `lte_server_ip` DB 동기화 | PASS | `preserved_keys_count: 36`(partial-merge 정책 준수), drift `dirty:false`, real apply NOOP |
| #1 | wifi 프로파일 추가(happy path) | PASS | wpa_supplicant 2-block conf 렌더, 복원 byte-identical(md5 동일) |
| #2 | 오류 PSK — wpa 인증 실패 자동 롤백 | PASS | `wpa_cli status` DISCONNECTED → ROLLING_BACK → `ROLLED_BACK`, wpa conf 이전 값 복원 |
| #3 | eth1 IP 변경 미confirm → TTL 자동 롤백 | PASS | **91초** 경과 후 `.99`→`.56` 자동 복원, `ROLLED_BACK` |
| #4 | eth1 IP 변경 confirm → 영속 COMMITTED | PASS | `COMMITTED`, 재부팅 후 `.99` 유지, 복원 byte-identical |
| #5 | country deferred + 재부팅 → firmware US 적용 | PASS | 재부팅 후 apply.service 자동 실행, `wpa_cli -i wlan0 get country` = `US`, `country_pending` 자동 해제 |
| #6 | country_now KR 즉시 적용 | PASS | 재부팅 없이 `wpa_cli get country` = `KR`, `_live_country=KR` 강화 검증 |
| #7 | wlan 모듈 강제 다운 자가복구(`rmmod wlan`) | PASS | watchdog 감지 → 복구 **51초**(`wlan0 UP` + `wpa_supplicant COMPLETED`), 수동 개입 0건, 스펙 한도 120초 내 |
| #8 | dpworldapp 재시작 무변화(§4.1 불변식) | PASS | networkd 렌더 파일 4종 + wifi-country-code + wpa_supplicant.conf **md5 전부 동일**, networkd 이벤트 0건 |
| #9 | CONFIRM_WAIT 중 SIGKILL → `recover_on_startup` 롤백 | PASS | 재시작 시 동일 boot_id(monotonic 유효) 기준 TTL 롤백, gateway 이전 값 복원, journal `RECOVER` |
| #10a | eth1 gateway 제거(§3.3-5 quirk 중화 증명) | PASS | 재부팅 없이 default 라우트 라이브 소멸, `networkctl reload` 43ms / `reconfigure` 27ms |
| #10b | eth1 gateway 복원(1차 FAIL → fix 후 PASS) | PASS | 결함 2건 수정(`7d6dab7`) 후 재시도, gateway `192.168.55.1` 복원, byte-identical |

**24h soak**: 2026-06-13 등록 완료, 익일 confirm 예정. 판정 기준은 watchdog `recover` 이벤트 0건 + `heartbeat` ≥1건 + network state `critical` 부재. CHANGELOG 의 "24h soak 등록 완료 — 익일 confirm" 기재대로 진행되며, soak 종료 검증 명령은 acceptance 문서 하단에 등재돼 있다([§9.4.5](#9-deployment--operations--배포운영)).

#### §8 §4.1 불변식 해석 정정 (조건부 판정)

#8 검증에서 dpworldapp 이 매 기동 시 `network_config.json` 을 자체 cJSON(tab 들여쓰기) 포맷으로 **재직렬화** 함이 확인됐다. 따라서 JSON 바이트 동일성은 불일치하지만, **값 수준 동일성은 완전 동일**(drift `dirty:false` 유지)하고 networkd 관련 렌더 파일은 byte-exact md5 동일이다. §4.1 불변식은 "JSON 바이트 동일성" 이 아닌 **"JSON 값 동일성 + 렌더 파일 byte-exact"** 로 정립됐다([§4.3](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더)). 이 사실은 내부 handoff 기록에 추가 문서화 대상이다.

### 10.2 검증 과정 요약 — phase별 2단 리뷰 + 종합 13건 + codex 5건

v1.6.0 은 기능 수락 PASS 만으로 운영 안전성을 보장하지 않는다는 원칙 하에 다단 검증을 거쳤다.

1. **단계별 2단 리뷰**: release design notes 기반 구현 중 다단 적대적 리뷰로 출시 전 60+건 차단 — 적용본-기준선 결함(Save→Apply NOOP), confirm-vs-TTL 레이스, country 게이팅 구멍, 포렌식 평문 유출, recover 유닛 tmpfs 설치, wpa 부팅 race 등(`CHANGELOG.md` Quality 항목).
2. **라이브 발견·수정 결함 4건**(수락 중 실기기에서만 재현 가능했던 통합 결함):

   | # | 증상 | 수정 | commit |
   |---|---|---|---|
   | D-1 | networkd 비동기 과도 상태 false fail(#10b 1차 FAIL) — reconfigure 20ms 후 eth1 주소 순간 `[]` | VERIFYING settle-retry(N회 polling + 최종 판정) | `7d6dab7` |
   | D-2 | 롤백 재검증 기준 오류 — 스냅샷 DB(새 값) 기준 비교 → 영구 불일치 | 기준 = 복원된 persist(이전 값) | `7d6dab7` |
   | D-3 | wlan0 carrier-DOWN 시 wpa-gate bypass(#2 틀린 PSK 커밋) | carrier DOWN이어도 `wpa_cli` 확인 강제 | `f5a6aff` |
   | D-4 | firmware apply.service `Requires=seed` 의존(#5 재부팅 후 FAILED_CRITICAL) | best-effort 전환 + `/lib` drop-in 으로 `Requires=` 제거 | `f5a6aff` |

   기기 선행 조건 수정: PrivateTmp=no(wpa control socket 공유) + eth0 link-DOWN watchdog 복구 억제 + `/run/wpa_supplicant` ReadWritePaths(`ed366ac`/`019a951`).
3. **출시 전 종합 리뷰 13건**(2026-06-13): 10/10 수락 PASS 후 6렌즈 × 2-skeptic 적대적 검증(50 에이전트)에서 수락·단계별 리뷰가 모두 놓친 **운영·생애주기** 결함 13건(HIGH 1 + MEDIUM 6 + LOW 6) 확인·수정. cold-start / startup block / 운영자 kill switch 부재 / 비밀 저장 권한(PSK 0644) / watchdog 재무장 / 검증 누락 클래스 — 사전 시드된 건강한 장비에서는 드러나지 않는 클래스. commit `8c649be`(startup 비동기·empty net_dir·dry-run probe·forensic budget), `d2cb031`(kill switch·재무장·권한·country 롤백 검증), `898f820`(유닛 설치 순서·dry-run 설명·SSID 바이트 길이). 950 pytest PASS / 1 skip / 46 npm.
4. **codex 2차 리뷰 대응 5건**(2026-06-14, commit `e92a5f7`): handoff 6 finding 의 adversarial 2차 검증. **H1** apply 임의 `device_config` 키 기록+롤백 미제거 → `netmodel.NETWORK_DEV_KEYS` allowlist + 미인식 키 400 거부; **H2** bool truthiness `"false"`→`True` → `_req_bool` strict 파서; **M1** watchdog eth DOWN+주소 drift 미표시 → 전환 시 1회 `{ifc}_down_addr_drift` WARN; **M2** `watchdog_interval_s` float 절삭 → strict int; **L1** fake clock 무한 루프 가능성 → 방어적 반복 상한. **969 pytest PASS / 1 skip / 46 npm PASS**, `.56` 라이브 13 케이스 **ALL_PASS**(H1 allowlist 거부 + H2 bool strict 거부 + M2 float/bool 거부 라이브 확인).

### 10.3 보안 자세 (Security Posture)

#### C1 — 미인증 API + CORS `*`: 릴리스 비차단, 정책 문서화

codex C1 finding 은 설정·network apply·firmware API 가 모두 미인증이라는 점을 지적했다. 근거: `src/server.py:78` 가 기본 `0.0.0.0` 바인딩, `src/server.py:201-205` 가 `Access-Control-Allow-Origin: *` 응답, 변경(mutating) 라우트는 `/setting/device`·`/setting/protocol`(`server.py:572-575`), firmware upload/flash(`server.py:588-592`), network apply/confirm/rollback/config(`server.py:595-617`).

**판정**: 릴리스 비차단. 미인증 API 와 CORS `*` 는 **v1.6.0 신규 회귀가 아니다** — 둘 다 초기 커밋(2026-02-13)부터 존재했으며, 이 프로젝트의 설계 전제인 **"폐쇄 LAN, 단일 운영자"** 운용 모델에서 의도된 결과다. CORS `*` 는 Java `CorsConfig` 미러로 전체 앱 계약상 단독 변경이 불가능하다.

단, v1.6.0 의 network reconfig API 추가로 **미인증 mutation 표면이 넓어진 것은 사실** 이며, 다음 운영 완화책(operational mitigation)을 권고한다(실 명령은 [§9.5](#9-deployment--operations--배포운영)):

- **HOST 바인딩 제한**: `server.py` 의 `0.0.0.0` 바인딩을 배포 환경 관리 인터페이스(eth1 등) IP 로 교체, 또는 systemd `IPAddressDeny=` 활용.
- **방화벽 allow-list**: iptables / nftables 로 9090 포트를 관리 대역(예: `192.168.55.0/24`)만 허용.

#### 남은 결정사항 / 백로그

- **(a) CORS 좁히기**: 전체 앱이 Java `CorsConfig` 동일 계약 공유 → 독립 변경은 사용자 결정 사안, 현재 보류.
- **(b) 미인증 전체 엔드포인트**: **SEC-2 로그인 백로그** 소유(HOLD). 목표는 firmware OTA 단독 보호가 아닌 Web Configurator 전체 endpoint 보호 + 운영자 password 1개. 200대+ fleet 에는 secret-per-device 모델이 부적합하다는 사용자 결정에 따라 SEC-2 spec 은 historical reference 로 HOLD, 로그인 기능 spec 은 "다른 기능 완료 후" 별도 작성.
- **(c) 운영 완화**: 위 HOST 바인딩 + 방화벽 allow-list 권고.

### 10.4 알려진 한계 / 백로그

#### dpworldapp 협의 대기 (handoff 항목 H)

v1.6.0 은 `/home/root/network/` 6종 파일을 dpworldapp 과 동일한 계약으로 직접 작성·적용하며 byte-exact golden 검증을 통과했다(H-1). 하지만 dpworldapp 단독 경로(Web Configurator 미경유)에는 펌웨어 측 버그가 남아 있어 협의·수정 대기 상태다. 우리 엔진의 byte-exact 렌더가 `cmp -s` no-op 으로 이를 중화하므로 Web Configurator 경유 적용에는 실해가 없다([§4.3](#4-22-field-contract--byte-exact-rendering--22항목-계약byte-exact-렌더)).

- **§3.3-4 load/save 비대칭**(H-3, 버그 리포트): deployed dpworldapp binary 의 필드 비교 로직은 `opc_ua_server_ip/port`·`modbus_server_ip/port` 4필드를 비교하지만 persist 로드 경로는 이 4필드를 JSON 에서 **읽지 않아**(로드 시 0 으로 초기화), OPC-UA/Modbus 설정이 있는 모든 production 장비에서 필드 비교 이 **항상 불일치** → dpworldapp 재시작마다 JSON 재작성 + apply.service 기동(churn). 권장 수정: load 경로에 4필드 역직렬화 추가.
- **§3.3-5 seed quirk**(H-4, 버그 리포트): `dpworld-network-apply.service` 의 `Requires=dpworld-network-seed.service` 때문에 apply 기동 시 seed(`apply.sh --boot`)가 먼저 실행되어 `cmp -s` 동일 판정 → gateway/metric-only 변경이 dpworldapp 단독 경로(재부팅)에서 **침묵 누락** 가능. v1.6.0 은 apply.service 완료 후 항상 `networkctl reload` + `reconfigure` 를 직접 수행해 중화([§9.1.3](#9-deployment--operations--배포운영)), #10a 에서 라이브 입증. 권장 수정: `Requires=` 제거 또는 cmp 기준선을 live 파일로 변경.
- **`lte_server_ip` 확인**(H-5, 확인 요청): `.56` 캡처에서 DB `device_config` = `10.226.22.48` vs 적용본 `network_config.json` = `104.208.105.62` 불일치 발견. A-0 수락에서 사용자가 `104.208.105.62` 유지로 확정·동기화했으나, 어느 쪽이 운영 의도인지 dpworldapp 팀 공식 확인이 남아 있다.

#### 기타 한계

- **Fresh-flash cold-start**: 최초 flash 시 빈 `/home/root/network` 에서의 미확인 롤백 lockout 은 종합 리뷰 #4(`8c649be`)로 수정됐으나(스냅샷 기준이 빈 경우 적용된 렌더 파일 유지, [§7.1(e)](#7-snapshot--rollback--journal--forensics--백업롤백저널포렌식)), 수락은 사전 시드된 건강한 `.56` 에서 수행됐으므로 진짜 cold-start 경로는 **`.54`(dev/verify) 검증 대상** 으로 남는다.
- **장비 RTC 시계 skew**: TTL/confirm 타이머는 엔진 자체 monotonic 타이머 기반(#3 91초, #9 동일 boot_id monotonic 유효)이라 wall-clock skew 에 내성이 있으나, journal/apply_state 의 wall-clock 타임스탬프는 장비 RTC 정확도에 의존한다([§3.2(c)](#3-apply-flow--state-machine--적용-흐름상태머신)).
- **watchdog `DOWN + address present` 의미론**(M1): 케이블-다운 복구 억제는 의도된 동작(`ed366ac` 자기파괴 방지)으로 유지하되, soak 중 실제 `DOWN` 샘플에 stale 주소가 포함되는지 관찰 후 가시성 정책 확정 — 현재는 전환 시 1회 WARN 만 추가([§5.4](#5-watchdog--self-healing--상시-감시자가복구)).

## 관련 문서 / References

| 종류 | 경로 |
|---|---|
| spec (Rev 2) | Internal source, not included in this export |
| plan | Internal source, not included in this export |
| changelog | `CHANGELOG.md` `[v1.6.0]` |
| acceptance(수락 시나리오 + soak 검증) | 내부 기록, 이 배포물에 미포함 |
| review handoff (+ Claude Review Response) | Internal source, not included in this export |
| dpworldapp handoff (항목 H) | Internal source, not included in this export |
| dpworldapp 파서 데이터 계약 | `docs/dpworldapp_schema_handoff/` (후보 스키마 v2/v3) |
| SEC-2 로그인 백로그 (HOLD, archived) | Internal source, not included in this export |