142 KiB
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). |
| 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 / 한눈에 보기
- 1. Overview · Motivation · Features / 개요·동기·기능
- 2. Architecture & Module Map / 아키텍처·모듈 지도
- 3. Apply Flow & State Machine / 적용 흐름·상태머신
- 4. 22-Field Contract & Byte-Exact Rendering / 22항목 계약·byte-exact 렌더
- 5. Watchdog & Self-Healing / 상시 감시·자가복구
- 6. HTTP API Contract / HTTP API 계약
- 7. Snapshot · Rollback · Journal · Forensics / 백업·롤백·저널·포렌식
- 8. UI / 사용자 인터페이스
- 9. Deployment & Operations / 배포·운영
- 10. Acceptance · Security · Known Limitations / 수락 결과·보안·한계·백로그
- 관련 문서 / 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)을 기준으로 무결성을 상시 보증한다.
flowchart TD
A["운영자: Wi-Fi / Ethernet / Server 설정 입력<br/>Save → DB device_config 기록"] --> B["Network → Apply & Status<br/>drift 배너: DB ≠ 적용본"]
B --> C{"변경 미리보기<br/>dry_run=true"}
C -->|"diff + 경고 뱃지<br/>(eth1 lockout / country reload / wifi 단절)"| D["적용 시작<br/>apply_async → apply_id (STARTED)"]
D --> E["VALIDATING → SNAPSHOT<br/>(적용 전 전체 백업 + DB 필드)"]
E --> F["WRITING (DB-first) → APPLYING<br/>(networkctl reload/reconfigure)"]
F --> G["VERIFYING<br/>link/carrier/주소·라우트/wpa/ping"]
G -->|"eth1 변경"| H{"CONFIRM_WAIT<br/>90s monotonic TTL"}
G -->|"eth1 무변경 & verify OK"| K["COMMITTED<br/>LKG 포인터 갱신"]
H -->|"새 IP 재접속 → confirm"| K
H -->|"TTL 만료 (재접속 실패)"| R["ROLLING_BACK"]
G -->|"verify fail & !force"| R
R -->|"복원+재적용+재검증 OK"| RB["ROLLED_BACK<br/>(이전 IP 복구)"]
R -->|"재검증 fail → recover 유닛 → 실패"| FC["FAILED_CRITICAL<br/>(포렌식 번들 수집)"]
K -.->|"운영 중 상시"| WD["Watchdog 30s tick<br/>적용본 기준 자가복구"]
핵심 요약 4줄:
- 분리: Save 는 DB 만, Apply 는 OS 반영 — 잘못 저장된 값이 곧바로 라이브에 닿지 않는다.
- 불변식: dpworldapp 의 매-재시작 재적용을 막을 수 없으므로, 렌더를 byte-exact 로 만들어
cmp -sno-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). 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/<apply_id>/에 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, Ciscocommit 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). - 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).
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)). 미적용(Save-only) 변경은 UI drift 뱃지가 "미적용 변경은 다음 재부팅 시 적용됩니다"로 인지시킨다(spec §4.1, §8.2.1).
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 모듈 의존 방향 — 단방향, 순환 없음
패키지 내부 의존은 단방향이며 순환이 없다. 상위(라우트/감시) → 하위(엔진) → 말단(렌더/검증/스냅샷/저널/모델)으로만 흐른다.
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-allowlistNETWORK_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 표 참조).
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
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 으로 응답한다:
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).
| 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())
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·§7.2(e). - (2)
watchdog.start()(watchdog.py:293)는loop()데몬 스레드를 띄워tick_once()를 interval 마다 호출한다. watchdog 은 절대 죽지 않도록 모든 예외를 삼킨다(watchdog.py:303-304, §5). - (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 는 watchdogsnapshot()1회 + drift + country_pending + iface 상태를system_status의network_apply키로 합쳐 노출하며, 실패해도{"error": ...}로 fail-soft 한다(server.py:189-190, UI 표시는 §8.4).
추가로 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-19bytevalidator.py:6-7)·escaping 부재("/\금지:9)·profile gap(빈 슬롯 뒤 항목은 침묵 탈락:46-52)을 데이터 차원에서 사전 거부한다(전체 규칙은 §4.5). - 포트/타입 정합:
render_persist_json의_port_num(renderer.py:51-55)은 dpworldapp cJSON 이 port 를 JSON number 로 기록한다는 캡처에 맞춰 String DB 값을 캐스팅한다. - 빌드 호환 검증(watchdog):
watchdog.py:14COMPAT_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).
설계의 위험 핵심은 단 하나다: 운영자가 eth1(원격 관리 인터페이스)의 IP 를 바꾸면, 적용 직후 운영자 자신의 연결이 끊어진다. 잘못된 설정이면 영영 못 돌아온다. 그래서 이 엔진의 거의 모든 불변(invariant)은 "운영자 lockout 방지"를 향한다.
3.1 전체 라이프사이클 (Lifecycle)
진입 — HTTP apply() (net_routes.py:31)
NetworkRoutes.apply(body) 가 단일 진입점이다. 흐름:
body["fields"]가 dict 인지 검사 (아니면RouteError(400)).- 화이트리스트 검사(
net_routes.py:38):fields의 모든 키가netmodel.NETWORK_DEV_KEYS에 속해야 한다. 임의device_config키가 apply 를 통해 들어오면 스냅샷이 그 키를 포착하지 못해 롤백으로도 제거할 수 없으므로(side-door), 미지 키는RouteError(400)로 즉시 거절. dry_run분기(_req_bool으로 엄격 bool 파싱): true 면engine.dry_run(...)결과만 반환 — 라이브 무접촉.- 실제 적용이면
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 입력 검증 가드).
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로 hungwpa_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)
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 을 막는다.
실패·종료 경로
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)
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)).
(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)).
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 부터는 livemodprobe -r경로를 완전히 제거했다. apply.service 는 drop-in (dpworld-network-apply.service.d/20-hardened.conf)으로 하드닝 스크립트 (deploy/dpworld-network-apply-hardened.sh)를 실행하는데, 이 스크립트는 country 를/etc/modprobe.d에 기록하고reboot-requiredmarker 만 세울 뿐 모듈을 내리지 않는다. 따라서 전문가 옵션country_now=true라도 같은 부팅에서는 라디오 실효 country 가 바뀌지 않으며, 실제 적용은 재부팅 시점이다. 상세는 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 로 들어간다:
confirm_deadline_monotonic = now + 90s설정, persist.timer_factory(91s, self.tick)데몬 타이머 기동(1차 만료 보장).- 운영자는 새 IP 로 재접속 한 뒤
confirm(apply_id)(net_routes.py:56→apply_engine.py:535) 호출 → 매칭되는 CONFIRM_WAIT 이면_commit(). - 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).
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
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 폴백
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). 따라서 우리가 쓰는 파일이 dpworldapp 이 쓰는 파일과 논리값·바이트 단위로 정확히 같아야 재시작 시 출렁임이 없다. 그 일치 단위가 바로 22항목(22-field) 집합이다.
핵심 코드는 두 모듈이다.
src/network/netmodel.py— DBdevice_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·§6 입력 검증 가드). 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), 설정된 장비에서는 필드 비교가 매번 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).
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=<m> - static 모드:
Address=<ip>/<prefix>(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=<sip>/32\nGateway=<gw>를 추가한다(renderer.py:26-30). subnet 판정 불가 시 보수적으로 host route 를 만들지 않는다(_in_subnet의except ValueError: return True).
[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). golden-file 테스트가 ZERO mismatch 를 회귀 가드한다. (단 #8 검증에서 dpworldapp 이 network_config.json 만은 자체 cJSON tab 포맷으로 재직렬화함이 확인돼, JSON 은 "값 동일성", networkd 렌더 파일은 "byte-exact" 로 불변식이 정밀화됐다 — §10.1의 정정 참조.)
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), 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() 가 이를 반복 호출한다.
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):
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 <gw> dev wlan0 존재 |
watchdog.py:124-130 |
eth0 / eth1 (per-iface) |
각 iface mode == "static" 이고 IP 있음 |
ip -br addr show <ifc> 에 의도 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 목록이다.
_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 <ifc>(실패한 그 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) |
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). - engine.tick() 무조건 호출 —
tick_once()첫 줄에서 억제조건·enabled 와 무관하게 항상self.engine.tick()을 호출한다(watchdog.py:206). watchdog 이 멈춰도 confirm TTL(확인 타임아웃) 안전망이 계속 돌게 하기 위함이다(§3.5). - 정상 틱 디스크 무기록 + 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_driftWARN 을 남긴다.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, 운영 절차는 §9.4.4).
| 키 | 기본값 | 효과 |
|---|---|---|
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).
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). 실제 상태머신 결과는 src/network/apply_engine.py 의 ApplyEngine 가 만든다(§3).
전 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=<apply_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=<n> |
없음 (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: <id>")로 변환.
country_now / force 의 의미
country_now— WiFi country code 변경은 wlan 모듈 리로드(10-20s 단절)를 유발하므로 기본은 재부팅까지 deferred 다.country_now=true동의 시 다른 변경과 함께 즉시 적용한다(apply_engine.py:365의 §6.2 전문가 즉시 적용 경로, §3.3). dry_run 단계에서도 동일 게이트가 미리 노출되어 dry_run OK 인데 apply 거절되는 divergence 를 방지한다(apply_engine.py:212).force— verify 실패 시에도 롤백하지 않고 강제 commit(apply_engine.py:386, 저널에force_commitwarn 기록).
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):
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·§4.1). 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 만 수용, 그 외엔 거절:
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)가 float10.9를 silently 10 으로 truncate 하는 것을 막기 위해 엄격 검사:bool(int 서브클래스) 명시 차단 +int아니면 400 + 범위[10, 300]벗어나면 400(net_routes.py:118).- 미지 키는
unknown key: <k>로 400, 유효 키가 하나도 없으면no valid keys to write400. 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"}}):
{
"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"}}):
{ "ok": true, "state": "STARTED", "apply_id": "ap-20260614-101530-742-3" }
상태 폴링(GET /api/network/apply/status?id=ap-...):
{
"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):
{ "ok": false, "error": "dry_run must be a boolean" }
watchdog 조회(GET /api/network/config):
{ "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: <reason>"}. - route 가
RouteError(status, message)를 raise 하면 그 status 코드로{"ok": false, "error": <message>}. - 그 외 예외는 traceback 출력 후
500 {"ok": false, "error": <str(e)>}.
즉 클라이언트는 비-2xx 응답에서 항상 {ok:false, error} 형태를 기대할 수 있다.
6.6 보안 posture (한 줄)
전 endpoint 는 미인증이며, 현재는 폐쇄(LAN-only) IoT 네트워크 전제 정책에 의존한다 — 인증·서명 보호는 별도 SEC-2 작업으로 분리되어 있다(상세 자세 평가는 §10.3, 운영 완화는 §9.5).
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), prunesrc/network/apply_engine.py— 롤백 본문(_rollback), DB 필드 캡처/복원, 상태 영속화(§3)src/network/journal.py— JSONL 저널 클래스 + 포렌식 번들
7.1 Snapshot — apply 직전 백업
apply 흐름에서 SNAPSHOT 단계에 진입하면(apply_engine.py:327-330) snapshot.take() 가 호출되어 파일 + DB 네트워크 필드 두 가지를 한 디렉토리(backups_dir/<apply_id>/)에 묶어 저장한다.
(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).
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 참조.
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)).
(b) 복원 순서 (DB-first → 파일 → 재적용 → 재검증)
- 스냅샷 무결성 확인:
snapshot.verify(snap)실패 시 즉시 예외 → 복구 사다리로(apply_engine.py:436-438) - DB-first 복원:
db_fields.json로드 후 fmt 2 면present복원 +absentpop. fmt 없는 구형 스냅샷은 전체를 present 로 취급(apply_engine.py:440-446). 복원은_write_db_restore(present, absent)(apply_engine.py:132-140) - 파일 복원:
snapshot.restore_files(snap, net_dir)(apply_engine.py:459). 빈 baseline 이면 적용본 렌더 유지 + warn 저널(apply_engine.py:449-458) - 재적용(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) - 재검증(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)). persist 판독 불가 시에만intent_from_device(present)폴백.result == "fail"이면 예외를 던져 FAILED_CRITICAL 사다리로(apply_engine.py:485-486) - country 변경 특례: country apply 의 롤백은 라디오의 실효 country 까지 확인한다. 라이브가 복원된 OLD 가 아니라 이번에 적용 시도한 NEW target 그대로면 라디오가 못 되돌아간 것 → FAILED_CRITICAL 로 에스컬레이트(
apply_engine.py:494-501) - 성공 시 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-<lkg>" 의 가상 _cur 를 구성해 _rollback(claimed=True) 호출. HTTP 진입점은 POST /api/network/rollback(§6.1).
(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.
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_KEYSjournal.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).
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):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_<apply_id>.tar.gz(§9.4.6).
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).
| 대상 | 권한 | 근거 |
|---|---|---|
스냅샷 디렉토리 <apply_id>/ |
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)이 전체 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).
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).
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="<apply_id>" 속성을 갖고, 클릭은 직접 바인딩이 아니라 위임(delegation) 으로 처리된다.
위임이 중요한 이유: eth1(웹 접속 경로) 변경 시 사용자는 새 IP 로 재접속 하여 페이지를 다시 로드해야 한다(net-apply.js:50-51, §3.5). 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) |
세 체크박스는 모달이 열릴 때마다 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 라인으로 <pre> 에 출력한다(net-apply.js:104-108). 최근 50건. 저널 내부 포맷은 §7.3.
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).
- 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 #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))을 뱃지로 압축 표시한다. 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). 문제가 생기면 상단 LKG 롤백 버튼으로 last-known-good 설정으로 되돌릴 수 있다(doRollback(), net-apply.js:225-230, confirm 다이얼로그 포함, §7.2(d)).
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.
[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, web-configurator.service:8-11) |
PrivateTmp=no |
공유 /tmp 사용 |
wpa_cli 가 응답 수신용 클라이언트 소켓을 /tmp/wpa_ctrl_<pid> 에 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, :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.
[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.3watchdog.py:18, §7.2(c)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.
[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).
9.2 deploy.ps1 네트워크 단계
근거: scripts/deploy.ps1. 고정 사실: $AppDir=/usr/lib/web-configurator, $Service=web-configurator, $Port=9090, SSH 는 root@<ip> + StrictHostKeyChecking=no -o BatchMode=yes(deploy.ps1:31-35).
배포 순서의 핵심 설계는 유닛/디렉토리 설치를 src swap 전에 수행 하는 것이다 — scp 실패 시 OLD src 가 디스크에 살아있는 상태로 중단되어 롤백 창을 보호한다(deploy.ps1:173-174).
- 디렉토리 사전 생성(
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).
web-configurator.service설치(:175-193): 첫 배포면 scp 후daemon-reload && enable. 이후엔sha256sum(원격) vsGet-FileHash(로컬) hash-compare — 변경 시에만 재설치 +daemon-reload.dpworld-net-recover.service설치(:204-222): 동일 hash-compare 패턴. oneshot 이라 enable 없이 파일만. 구버전이 만든 휘발성/etc/systemd/system/사본은rm -f로 제거(systemd 우선순위가/etc를 먼저 보기 때문).10-ondemand.confdrop-in 설치(:229-248):mkdir -p .../dpworld-network-apply.service.d후 hash-compare scp +daemon-reload. 디렉토리도 영속/lib하에 둔다.- 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@<ip> 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_<apply_id>.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·§6.5). 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).
- (선택) Dry-run —
POST /api/network/applybody{"fields":{...},"dry_run":true}. diff/errors/warnings 만 계산(라이브 무접촉). 경고 예:eth1_confirm,wifi_disrupt,country_reboot_deferred,dns_saved_only(apply_engine.py:206-225). - Apply —
POST /api/network/applybody{"fields":{...}}.fields는netmodel.NETWORK_DEV_KEYS화이트리스트만 허용(미지 키는 400 — 스냅샷이 못 잡는 side-door 차단,net_routes.py:35-40, §6.3). - Status 폴링 —
GET /api/network/apply/status?id=<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). - 상태 종합 —
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/confirmbody{"apply_id":"<id>"}로 확정해야 한다(net_routes.py:56-61,apply_engine.py:535-540). - 90초 내 confirm 이 없으면 엔진 타이머/폴링/watchdog 중 하나가 TTL 만료를 감지해 자동 롤백 한다 — 잘못된 IP 로 운영자가 영구 lockout 되는 것을 막는 안전장치(
apply_engine.py:542-553, §3.5). country_code변경은 별도 정책: 단독 변경이면서country_now미동의면COMMITTED + country_pending(재부팅 필요)로 deferred. 다른 필드와 혼합된 country 실효 변경은 v1.9 split-apply 로 분리(비-country 즉시 + country deferred)된다. v1.11.6 부터 country 는country_now동의 여부와 무관하게 livemodprobe -r없이 항상 재부팅 시점에 반영된다(apply_engine.py:185-203,:336-344, §3.3, firmware-boot-hardening.md).
9.4.3 롤백
- 자동: verify 실패(non-force) 또는 confirm TTL 만료, 또는 startup recovery 시 미완료 apply 발견 시. 스냅샷에서 DB(present 복원 + absent pop) + 렌더 파일 복원 후 재적용·재검증. 재검증 실패 시
dpworld-net-recover.service1회 호출 후에도 안 되면FAILED_CRITICAL(apply_engine.py:424-525, §7.2). - 수동(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).
- 현재 설정 조회:
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/configbody{"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/configbody{"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).
- 위치:
/opt/config_backups/network/forensic_<apply_id>.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). 코드 변경 없이 적용 가능한 운영 완화:
- 관리 인터페이스로만 바인딩: 메인 서비스 유닛에
Environment=HOST=<관리망 IP>를 추가(예: 운영자 접속용 eth1 의 고정 IP).server.py:78,1115가(HOST, PORT)로 bind 하므로 외부/필드망 인터페이스에서는 9090 이 열리지 않는다. 단, eth1 IP 를 네트워크 적용으로 바꾸는 경우 바인딩 주소와 충돌하지 않도록 주의(§9.4.2 confirm 절차와 병행). - 방화벽 allow-list: 9090(및 nginx 가 앞단이면 80/443)에 대해 운영자 워크스테이션 서브넷만 허용하는 ingress 규칙을 둔다. iptables 예시:
iptables -A INPUT -p tcp --dport 9090 -s <운영자_서브넷>/24 -j ACCEPT iptables -A INPUT -p tcp --dport 9090 -j DROP - 두 완화는 보완적이다 — 바인딩은 인터페이스 노출면을 줄이고, allow-list 는 동일 관리망 내 비인가 호스트를 차단한다. 근본적 엔드포인트 인증(로그인 기능)은 별도 SEC-2 트랙으로 분리되어 있으므로(§10.3), 그 전까지는 위 망/방화벽 레벨 완화를 운영 표준으로 적용한다.
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).
§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). 이 사실은 내부 handoff 기록에 추가 문서화 대상이다.
10.2 검증 과정 요약 — phase별 2단 리뷰 + 종합 13건 + codex 5건
v1.6.0 은 기능 수락 PASS 만으로 운영 안전성을 보장하지 않는다는 원칙 하에 다단 검증을 거쳤다.
-
단계별 2단 리뷰: release design notes 기반 구현 중 다단 적대적 리뷰로 출시 전 60+건 차단 — 적용본-기준선 결함(Save→Apply NOOP), confirm-vs-TTL 레이스, country 게이팅 구멍, 포렌식 평문 유출, recover 유닛 tmpfs 설치, wpa 부팅 race 등(
CHANGELOG.mdQuality 항목). -
라이브 발견·수정 결함 4건(수락 중 실기기에서만 재현 가능했던 통합 결함):
# 증상 수정 commit D-1 networkd 비동기 과도 상태 false fail(#10b 1차 FAIL) — reconfigure 20ms 후 eth1 주소 순간 []VERIFYING settle-retry(N회 polling + 최종 판정) 7d6dab7D-2 롤백 재검증 기준 오류 — 스냅샷 DB(새 값) 기준 비교 → 영구 불일치 기준 = 복원된 persist(이전 값) 7d6dab7D-3 wlan0 carrier-DOWN 시 wpa-gate bypass(#2 틀린 PSK 커밋) carrier DOWN이어도 wpa_cli확인 강제f5a6affD-4 firmware apply.service Requires=seed의존(#5 재부팅 후 FAILED_CRITICAL)best-effort 전환 + /libdrop-in 으로Requires=제거f5a6aff기기 선행 조건 수정: PrivateTmp=no(wpa control socket 공유) + eth0 link-DOWN watchdog 복구 억제 +
/run/wpa_supplicantReadWritePaths(ed366ac/019a951). -
출시 전 종합 리뷰 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. -
codex 2차 리뷰 대응 5건(2026-06-14, commit
e92a5f7): handoff 6 finding 의 adversarial 2차 검증. H1 apply 임의device_config키 기록+롤백 미제거 →netmodel.NETWORK_DEV_KEYSallowlist + 미인식 키 400 거부; H2 bool truthiness"false"→True→_req_boolstrict 파서; M1 watchdog eth DOWN+주소 drift 미표시 → 전환 시 1회{ifc}_down_addr_driftWARN; M2watchdog_interval_sfloat 절삭 → 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):
- HOST 바인딩 제한:
server.py의0.0.0.0바인딩을 배포 환경 관리 인터페이스(eth1 등) IP 로 교체, 또는 systemdIPAddressDeny=활용. - 방화벽 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).
- §3.3-4 load/save 비대칭(H-3, 버그 리포트): deployed dpworldapp binary 의 필드 비교 로직은
opc_ua_server_ip/port·modbus_server_ip/port4필드를 비교하지만 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), #10a 에서 라이브 입증. 권장 수정:Requires=제거 또는 cmp 기준선을 live 파일로 변경. lte_server_ip확인(H-5, 확인 요청):.56캡처에서 DBdevice_config=10.226.22.48vs 적용본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)), 수락은 사전 시드된 건강한.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)).
- watchdog
DOWN + address present의미론(M1): 케이블-다운 복구 억제는 의도된 동작(ed366ac자기파괴 방지)으로 유지하되, soak 중 실제DOWN샘플에 stale 주소가 포함되는지 관찰 후 가시성 정책 확정 — 현재는 전환 시 1회 WARN 만 추가(§5.4).
관련 문서 / 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 |