"` 속성을 갖고, 클릭은 직접 바인딩이 아니라 **위임(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 |