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

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 / 한눈에 보기

이 기능의 핵심 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 -s no-op 이 되게 한다(안정성 요건).
  • lockout 방지: eth1 변경은 90초 confirm-or-rollback(monotonic TTL), 크래시 시 startup recovery 가 미완료 apply 를 보수적으로 롤백.
  • 자가복구: 30초 watchdog 이 적용본 기준으로 무결성을 감시하되, 히스테리시스·cooldown·시간당 한도·WARN-only ping 으로 churn 을 억제한다.

1. Overview · Motivation · Features / 개요·동기·기능

Network Apply Engine 은 v1.6.0 에서 도입된 src/network/ Python 패키지로, 웹 configurator 에서 변경한 네트워크 설정(WiFi / eth0 / eth1 / WiFi country code / 서버 엔드포인트, 총 22항목)을 OS 에 즉시 적용·검증하고 실패 시 자동 롤백하며, 운영 중에는 상시 watchdog 으로 네트워크 무결성을 자가복구한다. dpworldapp(타 팀 바이너리)은 블랙박스로 두고 그 파일 계약(render contract)을 byte 단위로 그대로 재사용한다.

근거 문서: release design notes, implementation plan, CHANGELOG [v1.6.0](2026-06-13).

1.1 왜 만들었나 — 동기

기존에는 web configurator(Java app.jar 와 Python 신규 양쪽 모두)가 네트워크 설정을 SQLite board_config.device_config저장만 했다(spec §1). OS 반영(wpa_supplicant, systemd-networkd)은 dpworldapp 이 자기 재시작 시점에만 수행하므로, 웹에서 SSID/passkey/ethernet 을 바꿔도 즉시 반영되지 않았다.

Web → SQLite board_config.device_config            ← 여기서 끊김
         ↓ dpworldapp 재시작 시에만 (app_init)
dpworldapp: DB ↔ /home/root/network/network_config.json 비교 후 렌더·적용

이 끊김은 두 가지 실제 문제를 낳았다.

  • 즉시 반영 불가: 변경이 다음 재부팅/dpworldapp 재시작까지 OS 에 닿지 않음.
  • 실패 복구 수단 부재(실사고): eth_ip(eth1, 로컬 이더넷)를 잘못 바꾸면 웹 UI·SSH 접근 경로가 끊겨 SSH 가 유실되는 lockout 사고가 발생했다(CHANGELOG [v1.6.0] 배경). 적용 경로에 검증·롤백이 전혀 없었기 때문이다.

사용자가 제시한 목표는 3가지였다(spec §1): ① 설정이 실제로 OS 에 반영(Apply 시 즉시, 검증 포함), ② 운영 중 네트워크 무결성(드라이버가 내려간 채 방치되는 상황 방지 — 상시 감시·자가복구), ③ debug logging(문제 발생 시 사후 보완조치가 가능한 증거 수집).

1.2 무엇을 하나 — 핵심 동작과 A안 아키텍처

웹에서 바꾼 네트워크 22항목을 dpworldapp 파일 계약을 byte-exact 로 재사용해 OS 에 즉시 적용·검증·롤백하고, 30초 주기 watchdog 으로 무결성을 상시 보증한다(plan Goal). dpworldapp 을 수정하지 않는 대신, dpworldapp 이 쓰는 적용 경로(/home/root/network/ 렌더 파일 4종 + dpworld-network-apply.service)를 그대로 쓰고, 부족한 트리거·검증·롤백·로깅만 Python 안전계층으로 보강하는 A안 아키텍처를 채택했다(spec §2, §4).

이 설계의 핵심 불변식은 byte-exact 렌더다(spec §4.1, 자세히는 §4). 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/applydry_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, Cisco commit confirmed / MikroTik Safe Mode 패턴).
  • country deferred / 즉시 — country 변경은 modprobe -r wlan 리로드를 동반(IRQ78 패닉 이력 경로)하므로 기본 reboot-deferred(렌더만 일관 작성, apply.service 미트리거 + "Reboot required" 뱃지). 전문가 옵션 country_now=true 면 즉시 적용 + 강화 검증(spec §6.2).
  • 크래시 복구 — apply 상태+apply_id 를 디스크에 persist(apply_state.json). server.py 시작 시 미완료 apply 발견 → 보수적으로 자동 롤백. recover_on_startup() 은 HTTP 바인드를 막지 않도록 데몬 스레드로 실행(spec §5, CHANGELOG 리뷰 #1/#3).
  • 상시 watchdog 자가복구 — in-process 데몬 스레드, 30초 주기. 판정 기준은 DB 가 아니라 적용본 network_config.json. wlan 모듈/wpa/주소·라우트/eth0·eth1 per-iface 체크 + 복구 사다리. 억제 규칙: 히스테리시스(2회 연속 실패 시에만 발동) / cooldown 5분 / 시간당 최대 4회 / apply·confirm 중 일시정지 / gateway ping 은 WARN-only(자세히는 §5).
  • 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:3from network import netmodel(write-allowlist NETWORK_DEV_KEYS 검사용, apply() :38).
  • apply_engine.py:5from network import netmodel, renderer, snapshot, validator; verifier 는 순환·지연 회피를 위해 메서드 내부 :38에서 지연 import.
  • watchdog.py 는 생성자(:44)로 engine·journal 를 주입받고, 판정에서 netmodel/verifier 를 사용한다(외부 호출은 runner 주입 :47).
  • verifier.py:5from 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 fallbackserver.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_WATCHDOGNone 으로 남고, 모든 /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 는 watchdog snapshot() 1회 + drift + country_pending + iface 상태를 system_statusnetwork_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-19byte validator.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:14 COMPAT_DPWORLDAPP_MD5 가 렌더 계약이 검증된 dpworldapp 빌드의 md5 를 들고, start()_check_fw_md5()(watchdog.py:297)로 라이브 바이너리와 대조해 계약 drift 를 경고한다.
  • DB·파일 경계: 설정 DB(board_config/device_config)와 적용본 파일(/home/root/network/*)이 양측 공유 매체다. watchdog 의 판정 기준선은 DB 가 아니라 적용본 network_config.json(watchdog.py:1, apply_engine._baseline_intent() :88-90) — Save 가 이미 DB 를 갱신하므로 DB-vs-DB 비교는 Save→Apply 를 항상 NOOP 으로 만들기 때문이다.

정리하면, 이 서브시스템은 dpworldapp 의 입력 파일을 정확히 그 형식대로 써 주고, dpworldapp 가 보는 그대로 라이브 상태를 검증·복구할 뿐, dpworldapp 바이너리·프로세스에는 손대지 않는다(파일 수정 0, 코드 수정 0).

3. Apply Flow & State Machine / 적용 흐름·상태머신

네트워크 설정 변경을 라이브 시스템에 안전하게 반영하는 핵심 엔진이다. 단순히 파일을 쓰고 networkctl reload 를 부르는 수준이 아니라, 검증 → 스냅샷 → 쓰기 → 적용 → 검증 → (eth1) 확인대기 → 커밋 의 다단계 상태머신으로 구성되며, 어느 단계에서 실패하거나 프로세스가 죽어도 라이브 네트워크를 깨뜨리지 않도록 롤백·크래시 복구 경로를 갖췄다. 구현은 전부 src/network/apply_engine.pyApplyEngine 클래스이고, HTTP 진입점은 src/network/net_routes.pyNetworkRoutes.apply() 이다(§6).

설계의 위험 핵심은 단 하나다: 운영자가 eth1(원격 관리 인터페이스)의 IP 를 바꾸면, 적용 직후 운영자 자신의 연결이 끊어진다. 잘못된 설정이면 영영 못 돌아온다. 그래서 이 엔진의 거의 모든 불변(invariant)은 "운영자 lockout 방지"를 향한다.

3.1 전체 라이프사이클 (Lifecycle)

진입 — HTTP apply() (net_routes.py:31)

NetworkRoutes.apply(body) 가 단일 진입점이다. 흐름:

  1. body["fields"] 가 dict 인지 검사 (아니면 RouteError(400)).
  2. 화이트리스트 검사(net_routes.py:38): fields 의 모든 키가 netmodel.NETWORK_DEV_KEYS 에 속해야 한다. 임의 device_config 키가 apply 를 통해 들어오면 스냅샷이 그 키를 포착하지 못해 롤백으로도 제거할 수 없으므로(side-door), 미지 키는 RouteError(400) 로 즉시 거절.
  3. dry_run 분기(_req_bool 으로 엄격 bool 파싱): true 면 engine.dry_run(...) 결과만 반환 — 라이브 무접촉.
  4. 실제 적용이면 engine.apply_async(...) 호출. 반환이 BUSYRouteError(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 로 hung wpa_cli 가 응답을 ~19s 블록하지 못하게 한다.
  • 경고로 eth1_confirm, wifi_disrupt, country_reboot_deferred, dns_saved_only(dns1/dns2 는 현 펌웨어 미적용 dead field), country_deferred_pending 등을 부착.

apply_async — 즉시 반환 + 데몬 스레드 (apply_engine.py:252)

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} 생성, _curVALIDATING 으로 초기화 후 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 부터는 live modprobe -r 경로를 완전히 제거했다. apply.service 는 drop-in (dpworld-network-apply.service.d/20-hardened.conf)으로 하드닝 스크립트 (deploy/dpworld-network-apply-hardened.sh)를 실행하는데, 이 스크립트는 country 를 /etc/modprobe.d 에 기록하고 reboot-required marker 만 세울 뿐 모듈을 내리지 않는다. 따라서 전문가 옵션 country_now=true 라도 같은 부팅에서는 라디오 실효 country 가 바뀌지 않으며, 실제 적용은 재부팅 시점이다. 상세는 firmware-boot-hardening.md 참조.

상황 동작
deferred (기본) country-only 변경 + country_now 미동의 → 파일/DB 만 쓰고 apply.service 미기동, COMMITTED + country_pending=True. 재부팅 시 실제 반영.
country_now (전문가 옵션) 동의 시 apply.service(=하드닝 스크립트)가 modprobe.d 를 갱신하고 reboot-required marker 를 세운다 — live 모듈 리로드 없음(v1.11.6). 엔진은 이후 강화 검증 사다리(/sys/module/wlan/sys/class/net/wlan0_live_country == target)를 돌리는데, 모듈을 내리지 않았으므로 라디오 실효 country 는 아직 OLD 라 보통 검증 불일치 → 롤백(apply_engine.py:413-427). 실효 변경은 재부팅 후에야 일어난다.
혼합 (v1.9 split-apply) country 실효 변경 + 다른 필드 동반(len(diff)>1) → 비-country 필드는 즉시 적용, country 는 deferred 로 분리(split-apply).
보류 중 동결 직전 country 가 재부팅 대기(pending)인데 추가 apply 시도 + country_now 미동의 → 거부(deferral 분기로 새는 silent commit 차단).

이력 참고: 위 표의 country_now 행은 코드 경로(엔진이 여전히 강화 검증 사다리를 돈다)를 그대로 기술한 것이다. v1.11.6 이전 빌드는 이 경로에서 모듈을 실제로 리로드해 재부팅 없이 즉시 반영했으나, 현재는 하드닝 스크립트가 리로드를 거부하므로 실효 변경이 재부팅으로 미뤄진다.

3.4 force 모드 (검증 생략, 저널 기록)

force=True 면 VERIFYING 에서 verify fail 이 나도 롤백하지 않고 강행한다(apply_engine.py:386). 단 silent 가 아니라 저널에 force_commit WARN 을 남긴다. VALIDATING 단계의 step detail 에도 [force] 를 명시한다(apply_engine.py:322). 운영자가 verify 로직이 못 따라가는 특수 토폴로지를 강제 적용할 탈출구이되, 흔적은 반드시 남긴다. (country 게이트 자체는 force 로 우회되지 않는다 — VALIDATING 의 errs 검사는 force 무관하게 거절.)

3.5 eth1 confirm 흐름 (apply_engine.py:391, confirm/tick)

eth1 이 바뀌면 VERIFYING 통과 후 즉시 COMMITTED 하지 않고 CONFIRM_WAIT 로 들어간다:

  1. confirm_deadline_monotonic = now + 90s 설정, persist.
  2. timer_factory(91s, self.tick) 데몬 타이머 기동(1차 만료 보장).
  3. 운영자는 새 IP 로 재접속 한 뒤 confirm(apply_id)(net_routes.py:56apply_engine.py:535) 호출 → 매칭되는 CONFIRM_WAIT 이면 _commit().
  4. 90초 내 confirm 이 없으면(= 새 설정이 깨져 재접속 실패) tick()(apply_engine.py:542)이 마감 경과를 감지하고 _claim(("CONFIRM_WAIT",), "ROLLING_BACK") 승리 시 자동 롤백 → 구 IP 가 복구되어 운영자가 다시 들어올 수 있다.

tick() 은 세 경로에서 호출된다: 엔진 자체 타이머(1차), watchdog, 그리고 status/state 폴링(net_routes.py:53,72 — 폴링이 안전망 타이머 역할). confirm 과 tick 의 race 는 _claim CAS 가 단일 소비를 보장한다. is_busy_or_confirming()(apply_engine.py:558)은 watchdog 의 복구 사다리가 CONFIRM_WAIT 중인 eth1 확인 대기를 침범하지 못하게 억제하는 데 쓴다(§5.4).

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 — DB device_config → 중간 표현 intent 변환 + 비교 의미론(comparison semantics)
  • src/network/renderer.pyintent → 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:99snapshot.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_subnetexcept 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 showdefault 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 쿼리 실패는 UNKNOWNwpa_cli statusrc != 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 자동 재무장 + 수동 resettick_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_drift WARN 을 남긴다. MemoryMax=48M 제약 하에서 수십 MB 바이너리를 통째 읽지 않도록 1MB 청크 로 읽는다(I6, watchdog.py:280).
  • 절대 죽지 않는 looploop()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_enabledwatchdog_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.pydo_GET(445-459) / do_POST(595-626) 분기에서 시작하고, request body 검증을 통과한 dict 를 src/network/net_routes.pyNetworkRoutes 메서드로 위임한다(§2.3). 실제 상태머신 결과는 src/network/apply_engine.pyApplyEngine 가 만든다(§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_commit warn 기록).

6.3 입력 검증 가드 (강조)

이 subsystem 은 4개의 엄격한 입력 가드로 silent 변형·side-door 를 차단한다.

1. fields 네트워크 allowlist (NETWORK_DEV_KEYS)

apply()fields 의 모든 키가 netmodel.NETWORK_DEV_KEYS(netmodel.py:11, 20개 네트워크 키)에 속하는지 검사하고, 하나라도 벗어나면 거절한다(net_routes.py:38):

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_sint(v) 가 float 10.9 를 silently 10 으로 truncate 하는 것을 막기 위해 엄격 검사: bool(int 서브클래스) 명시 차단 + int 아니면 400 + 범위 [10, 300] 벗어나면 400(net_routes.py:118).
  • 미지 키는 unknown key: <k> 로 400, 유효 키가 하나도 없으면 no valid keys to write 400.
  • reset_watchdog_critical:true 는 단일 의도이므로 다른 키와 동시 전송 시 400(net_routes.py:104).

4. 비-dict body 400

server 계층에서 _read_request_body(server.py:251)가 JSON 을 parse 한 뒤, 각 POST 핸들러가 dict 가 아니면(JSON array [1,2,3] / scalar 등) 400 으로 거절한다(server.py:600, :610, :622). 빈 body·Content-Length 이상·과대 payload 도 body reader 가 각각 400/413 으로 차단한다(server.py:259-271). route 메서드 내부 config() 도 자체적으로 if not isinstance(body, dict): RouteError(400) 를 한 번 더 가진다(net_routes.py:102).

6.4 실제 응답 JSON 예시

dry_run 미리보기(POST /api/network/apply, {"dry_run":true, "fields":{"wifi_ip":"192.168.55.60"}}):

{
  "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), prune
  • src/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.tmpos.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: listdirgetmtime 사이 디렉토리가 사라져도 무시(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 tmpos.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 → 파일 → 재적용 → 재검증)

  1. 스냅샷 무결성 확인: snapshot.verify(snap) 실패 시 즉시 예외 → 복구 사다리로(apply_engine.py:436-438)
  2. DB-first 복원: db_fields.json 로드 후 fmt 2 면 present 복원 + absent pop. fmt 없는 구형 스냅샷은 전체를 present 로 취급(apply_engine.py:440-446). 복원은 _write_db_restore(present, absent)(apply_engine.py:132-140)
  3. 파일 복원: snapshot.restore_files(snap, net_dir)(apply_engine.py:459). 빈 baseline 이면 적용본 렌더 유지 + warn 저널(apply_engine.py:449-458)
  4. 재적용(re-apply): dpworld-network-apply.servicebest-effort(must_ok=False) — rc≠0 이면 warn 만(apply_engine.py:462-467). 실제 적용 수단은 networkctl reload, 변경된 iface 별 networkctl reconfigure, wpa 변경 시 wpa_cli reconfigure(apply_engine.py:468-473)
  5. 재검증(re-verify) — 실 게이트: 검증 기준은 복원된 persist(network_config.json 에서 intent_from_persist)이다(apply_engine.py:478-484). Save-then-Apply drift flow 에서 스냅샷 DB 는 NEW 값이라 그 기준으로 재검증하면 복원된 라이브(OLD)와 영원히 불일치 → 가짜 FAILED_CRITICAL 이 나기 때문(§3.2(e)). persist 판독 불가 시에만 intent_from_device(present) 폴백. result == "fail" 이면 예외를 던져 FAILED_CRITICAL 사다리로(apply_engine.py:485-486)
  6. country 변경 특례: country apply 의 롤백은 라디오의 실효 country 까지 확인한다. 라이브가 복원된 OLD 가 아니라 이번에 적용 시도한 NEW target 그대로면 라디오가 못 되돌아간 것 → FAILED_CRITICAL 로 에스컬레이트(apply_engine.py:494-501)
  7. 성공 시 state = ROLLED_BACK 영속화(apply_engine.py:502). DB 는 의도적으로 user-saved 값 그대로 두어, persist 와 다르면 drift 로 남아 재시도 가능하게 하고 이를 저널 warn 으로 명시(apply_engine.py:503-514)

(c) 실패 시 복구 사다리 → FAILED_CRITICAL

위 try 안의 어떤 단계든 예외가 나면 마지막 사다리로 dpworld-net-recover.service 를 1회 기동(60s)하고, state = FAILED_CRITICAL 로 마킹한 뒤 reason 을 담아 반환한다(apply_engine.py:517-525).

(d) 수동 롤백 API — rollback_to_lkg

rollback_to_lkg()(apply_engine.py:562-579)는 LKG 스냅샷으로 되돌린다. in-flight apply/CONFIRM_WAIT 중이면 진행 중 apply 의 _cur 강탈을 막기 위해 BUSY 반환(apply_engine.py:566-567). LKG 가 없으면 INVALID. 있으면 apply_id = "rb-<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_KEYS journal.py:6)이면 값을 "***" 로 치환(journal.py:30-35). 저널에 평문 비밀이 남지 않게 한다.
  • tail(n): support bundle/진단용. live + 로테이션 파일을 newest-first 로 모아 마지막 n줄을 파싱하되, lock 없이 읽어 torn line 은 json.loads 실패 시 건너뛴다(journal.py:74-93). HTTP 진입점은 GET /api/network/journal?limit=(§6.1).

7.4 Forensic bundle — 증거 수집

forensic_bundle()(journal.py:96-154)은 ROLLED_BACK / FAILED_CRITICAL 양쪽 모두 _rollbackfinally 에서 수집된다(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.enabledenabled(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_idstate(기본 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-containerinnerHTML 만 교체하고 엘리먼트는 재사용하므로, 방문마다 리스너가 누적되어 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 warningseth1_confirm 포함 시 행 노출(:132) 미체크면 적용 시작 비활성(게이트, :141-147)
country 지금 적용 net-country-now warningscountry_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 가 시작되면(doApplypollEpoch++, :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/confignet_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 criticalwatchdog 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.3 watchdog.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.serviceRequires=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).

  1. 디렉토리 사전 생성(deploy.ps1:146, :198):
    • mkdir -p $AppDir /opt/fw_staging /opt/config_backupsProtectSystem=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_VERSIONversion=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).

  1. (선택) Dry-runPOST /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. ApplyPOST /api/network/apply body {"fields":{...}}. fieldsnetmodel.NETWORK_DEV_KEYS 화이트리스트만 허용(미지 키는 400 — 스냅샷이 못 잡는 side-door 차단, net_routes.py:35-40, §6.3).
  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).
  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":"<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 동의 여부와 무관하게 live modprobe -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.service 1회 호출 후에도 안 되면 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/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).

  • 위치: /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). 코드 변경 없이 적용 가능한 운영 완화:

  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 예시:
    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 / 수락 결과·보안·한계·백로그

이 섹션은 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 만으로 운영 안전성을 보장하지 않는다는 원칙 하에 다단 검증을 거쳤다.

  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-205Access-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.py0.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).

  • §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.serviceRequires=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 캡처에서 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)), 수락은 사전 시드된 건강한 .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