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.

851 lines
41 KiB

# NEW Web Configurator v1.5.4.2 — 시스템 아키텍처
**문서 버전**: 1.0
**앱 버전**: v1.5.4.2 (SHA `fe5cd25`, 태그 `v1.5.4.2`)
**브랜치**: `feature/v1.5-ia-redesign` (origin 푸시 완료)
**작성일**: 2026-06-08
**상태**: dev/verify 환경 .54 (192.168.55.54), 운영 .56은 v1.4.6.10 유지
**상위 문서**: `docs/architecture.md` (v2.2, v1.3.0 시대까지 커버) — 이 문서가 해당 문서를 대체함
---
## 1. 요약
**NEW Web Configurator**는 Telechips TCC8030 IoT 디바이스를 위한 브라우저 기반 설정 도구다. **Python stdlib(표준 라이브러리) 전용 HTTP 서버** (pip 없음, 프레임워크 없음)와 **바닐라 ES 모듈 SPA** (빌드 단계 없음, 번들러 없음)로 구성된다. 기존 Java/Spring Boot 기반 설정 도구를 대체한 것으로, 해당 도구는 TCC8030의 700 MHz ARM에서 메모리를 436 MB 이상 점유했다 — 각 백그라운드 서비스에 48 MB MemoryMax 예산이 배정된 디바이스에서. Python 프로세스는 RSS 기준 10–30 MB 수준으로 동작한다.
핵심 설계 원칙:
1. **LAN 전용 IoT 환경** — 외부 노출 없음, 인증 없음. 모든 클라이언트는 192.168.55.x (디바이스 LAN 세그먼트) 내부에 있다. CORS는 설계상 전체 개방.
2. **SQLite 공유 소유권** — 세 개의 독립 프로그램(이 서비스, 레거시 Java app-runner, `dpworldapp` 데이터 처리 바이너리)이 동일한 `~/db/dynamic_data.db`를 읽고 쓴다. 키 수준 소유권 정책(`board_config_key_ownership_policy`)이 주된 안전 메커니즘이다.
3. **config-reader 계약 준수**`device_config``protocol_config`는 디바이스 config-reader 계약과 정확히 일치해야 한다. 이를 통해 레거시 Java 앱이 .56에서 읽고 쓰는 동작이 예기치 않게 깨지지 않도록 보장한다. 자동화된 드리프트 감지가 이를 강제한다.
4. **Partial-merge(부분 병합)만 허용** — 전체 행 REPLACE 없음. `DBManager.update_config` (BEGIN IMMEDIATE RMW)가 모든 POST에서 미설정 키를 보존하도록 보장한다.
5. **v1.5.x IA 재설계** — 사이드바 중첩 5그룹 네비게이션, Firmware OTA 실제 통합, Lucide SVG 아이콘, Inter/JetBrains Mono 셀프 호스팅 폰트, gzip + ETag/304 성능 최적화, behavioral(jsdom) 테스트 인프라.
---
## 2. 하드웨어 컨텍스트 (Telechips TCC8030)
| 항목 | 값 |
|---|---|
| CPU | 700 MHz ARM (싱글코어 주, 멀티코어 가능) |
| OS | Yocto/Poky 4.0.17 (`uname -r`: Telechips) |
| 펌웨어 업데이트 | A/B 파티션 슬롯 (`slot A` / `slot B`, dpw-fw-update-tool 경유) |
| 영속 파티션 | `/home/root/db` (mmcblk0p11) — SQLite DB; `/opt/log` (mmcblk0p12) — dpworldapp 로그 |
| 임시 파티션 | `/tmp` — tmpfs, 서비스별 격리 슬라이스 PrivateTmp=yes |
| `/etc/systemd/system` | **tmpfs** — 여기에 작성된 유닛 파일은 **재부팅 시 소실됨** |
| systemd 유닛 위치 | `/lib/systemd/system/web-configurator.service` (영속, v1.5.2 H17 수정) |
| MemoryMax | 48 MB (서비스 유닛에 설정) |
| Web Configurator 포트 | **9090** (직접 연결, 앞단 nginx 없음) |
| Java app-runner 포트 | **8080** (.56에서 nginx :80 → Java :8080) |
| WiFi 칩셋 | Qualcomm QCA6490 (PCI 17CB:1103, 드라이버 cnss_pci) |
| 펌웨어 바이너리 | `/lib/firmware/amss.bin` (WiFi FW 버전은 `QC_IMAGE_VERSION_STRING=`으로 추출) |
| dpworldapp 바이너리 | `/usr/bin/dpworldapp` — CAN/Modbus/OPC-UA 데이터 처리기 |
| GNSS | u-blox GPS (버전은 dpworldapp 로그 `[GNSS FW VER : ...]`에서 확인) |
| 앱 로그 경로 | `/opt/log/dpworldapp/dpworldapp_YYYY-MM-DD.log` |
**tmpfs 인시던트** (2026-05-29): Poky 4.0.17에서 `/etc/systemd/system/`은 tmpfs다. v1.4.0 배포 시 유닛을 `/etc/`에 작성했고, 다음 재부팅 전까지는 유지되었다. v1.5.2 H17에서 deploy.ps1이 `/lib/systemd/system/`(영속)에 쓰도록 수정했다.
**ReadWritePaths**: systemd 유닛은 `ProtectSystem=strict`를 사용한다. 서비스가 쓰기 접근해야 하는 경로는 `ReadWritePaths`에 명시적으로 나열해야 한다:
- `/opt/log/dpworldapp` — 로그 다운로드 임시 부모 경로 (v1.4.6.6에서 `/tmp`로 수정)
- `/opt/fw_staging` — 펌웨어 스테이징 디렉토리 (v1.5.2 C1)
- `/opt/config_backups` — 플래시 전 설정 백업 (v1.5.2 C1)
- `/home/root/db` — SQLite DB 경로
---
## 3. 기술 스택
### 백엔드
| 컴포넌트 | 기술 |
|---|---|
| HTTP 서버 | Python 3.10+ `http.server.BaseHTTPRequestHandler` + `socketserver.ThreadingMixIn` |
| 데이터베이스 | `sqlite3` stdlib |
| 압축 | `gzip` stdlib (응답 압축) |
| 설정 | `os.environ` + 상수 |
| 의존성 | **Python stdlib 전용** (pip 없음, 서드파티 패키지 없음) |
| 진입점 | `src/server.py:main()` |
### 프론트엔드
| 컴포넌트 | 기술 |
|---|---|
| JS 모듈 시스템 | 바닐라 ES 모듈 (`type="module"`, 번들러 없음) |
| 아이콘 | Lucide v0.460+ SVG 인라인 (`src/static/js/icons.js`, 49개 항목) |
| 타이포그래피 | Inter (5 웨이트) + JetBrains Mono (2 웨이트) — `.woff2` 셀프 호스팅 (`src/static/fonts/`) |
| 스타일시트 | 단일 `src/static/css/style.css` (~3000줄 이상) |
| XSS 이스케이프 | `src/static/js/utils.js``escapeHtml` (5문자 명시 치환: `& < > " '`) |
### 테스트
| 컴포넌트 | 기술 |
|---|---|
| Python 테스트 | `pytest` (v1.5.4.2 기준 644개 테스트) |
| JS behavioral 테스트 | Node 18+ `node:test` + `jsdom` (4개 `.mjs` 파일에 35개 테스트) |
| 듀얼 러너 | `python -m pytest` + `npm test` |
| 디바이스 배포 격리 | `deploy.ps1``git archive HEAD src` 사용 — tests/node_modules/package.json은 배포 대상 아님 |
### 배포
- PowerShell `scripts/deploy.ps1` — git archive → scp → tar → atomic mv → systemctl restart
- 헬스 체크 + 실패 시 자동 롤백 (v1.5.2 H18)
- `/lib/systemd/system/` 유닛 설치 (v1.5.2 H17 영속화)
---
## 4. 프로세스 모델
```
systemd web-configurator.service
└── Python process (server.py main())
├── ThreadedHTTPServer (ThreadingMixIn + HTTPServer)
│ └── ConfigHandler per thread (BaseHTTPRequestHandler)
├── Log auto-compress daemon thread (5-min interval)
└── _recover_staging background daemon thread (firmware, v1.5.2 M10/M15)
```
- `socket.setdefaulttimeout(30.0)` — 글로벌 slowloris 방어 (v1.5.1 H4).
비활성 상태 30초 후 모든 연결이 타임아웃된다.
- `/api/firmware/upload` 라우트는 대용량 펌웨어 파일 업로드 완료를 허용하기 위해 연결별로 `connection.timeout = None`으로 재설정한다 (v1.5.2 H20).
- `ThreadedHTTPServer.timeout = 30.0` — 서버 수준 accept 타임아웃.
- 서버 프로세스당 `DBManager` 인스턴스 하나 (`server.py:89`의 모듈 수준 `db = DBManager()`). 각 public 메서드는 SQLite 연결을 건드리기 전에 `self._lock` (threading.Lock)을 획득한다.
---
## 5. 데이터 레이어
### 데이터베이스 파일
```
/home/root/db/dynamic_data.db (mmcblk0p11, 영속 파티션)
```
### 테이블
| 테이블 | 소유자 | 목적 |
|---|---|---|
| `board_config` | 공유 (Python + Java + dpworldapp + Super_Relay) | 키-값 설정 저장소 |
| `event_history` | dpworldapp | 이벤트 큐 — Web Configurator는 쓰기 안 함 |
| `schema_meta` | Python Web Configurator | 마이그레이션 플래그 (멱등 시작) |
| `sqlite_sequence` | SQLite 내부 | AUTOINCREMENT 추적 |
| `board_config_audit` | Python Web Configurator | 설정 변경 감사 로그 |
### board_config 키 — 소유권
| 키 | 소유자 | Java 접근 여부 | dpworldapp 읽기 여부 | 비고 |
|---|---|---|---|---|
| `device_config` | Python + Java **공유** | 예, 전체 행 REPLACE | 예 | device config-reader 계약 적용 — 37개 필드 |
| `protocol_config` | Python 주 | 예 (.56 구버전) | 예 | device register-mapping 계약 — ~21개 필드 |
| `log_config` | Python 전용 | 아니오 | 아니오 | Web Configurator 로그 관리 — v1.0.2 |
| `security_config` | Super_Relay 프로젝트 | 아니오 | 아니오 | 외부 프로젝트 — 건드리지 말 것 |
| `transport_config` | Super_Relay 프로젝트 | 아니오 | 아니오 | 외부 프로젝트 — 건드리지 말 것 |
**핵심 원칙**: Python 전용 설정은 반드시 별도 키에 넣어야 한다(`device_config`/`protocol_config` 금지). Java의 전체 행 REPLACE가 모든 Java POST 시 Python 전용 필드를 조용히 삭제하기 때문이다.
### DBManager 백엔드 (db_manager.py)
우선순위 체인:
1. `python``sqlite3` stdlib 모듈 (권장; 운영 환경에서 사용)
2. `cli` — subprocess 경유 `sqlite3` CLI (import 실패 시 폴백)
3. `json` — JSON 파일 폴백 (테스트/긴급 용도만; dpworldapp 호환 없음)
**Atomic RMW** (`db_manager.py:226` `update_config`):
- `python` 백엔드: 단일 연결 내에서 `BEGIN IMMEDIATE` + SELECT + `INSERT OR REPLACE` + COMMIT 수행. 동시 쓰기 차단. 지수 백오프로 잠금 재시도 (3회, 100 ms 기본).
- `cli` + `json` 백엔드: `self._lock`으로 직렬화.
- 결과: N개 동시 스레드 간 업데이트 유실 없음 (10스레드 + 20스레드 동시 증분 테스트 통과 확인 — `tests/test_v1_5_3_multi_instance.py`).
### 마이그레이션 (src/migrations.py)
시작 시 `apply_all_migrations(db)` (`server.py:728`)를 통해 적용됨.
Fail-soft: `(OperationalError, DatabaseError, JSONDecodeError, ValueError)` 예외를 잡아서 처리.
| 마이그레이션 | schema_meta 키 | 수행 내용 |
|---|---|---|
| `migrate_can_baudrate_units` | `can_baudrate_units_v2` | 구 baudrate 값 2500/5000/10000 → 250/500/1000으로 재매핑 |
| `migrate_log_compress_split` | `log_compress_split_v1` | `log_compress_size_mb`/`log_compress_age_days`를 `device_config``log_config`로 이동 |
| `migrate_port_types` | `port_types_int_v1` | 문자열 타입 서버 포트를 Integer로 정규화 |
---
## 6. 요청 라우팅 (server.py)
### do_GET 라우트
| 경로 | 핸들러 | 설명 |
|---|---|---|
| `/setting/get-device` | `_handle_get_device` | `device_config``log_config` 병합 반환 (log 필드 삽입) |
| `/setting/get-protocol` | `_handle_get_protocol` | `protocol_config` 반환 |
| `/setting/log-files` | `_handle_get_log_files` | dpworldapp 로그 파일 목록 |
| `/setting/kernel-bundle` | `_handle_get_kernel_bundle` | journalctl + 커널 로그 `.tar.gz` (v1.1.0) |
| `/setting/log-stats` | `_handle_get_log_stats` | 로그 디스크 사용량 통계 |
| `/api/mac` | `_handle_get_mac` | WiFi MAC 주소 (디바이스 식별) |
| `/api/health` | `_handle_health` | 서버 헬스 (uptime, DB 백엔드) |
| `/api/system-status` | `_handle_system_status` | 홈 대시보드: core_app / communication / network / system / hardware_modules / dpworldapp_status |
| `/api/support-bundle` | `_handle_support_bundle` | 진단 ZIP 다운로드 |
| `/api/firmware/status` | `_FW.status()` | Firmware OTA 상태 스냅샷 (v1.5.0 P4a) |
| 정적 파일 | `_serve_static_file` | Realpath 경로 순회 방어 + gzip + ETag + Cache-Control |
### do_POST 라우트
| 경로 | 핸들러 | 설명 |
|---|---|---|
| `/setting/device` | `_handle_post_device` | device config partial-merge (atomic RMW) |
| `/setting/protocol` | `_handle_post_protocol` | protocol config partial-merge + `process_protocol_config` |
| `/setting/log-download` | `_handle_post_log_download` | 선택한 로그 파일 → `.tar.gz` |
| `/api/test-connections` | `_handle_test_connections` | 설정된 서버 엔드포인트 TCP 프로브 |
| `/api/restart-dpworldapp` | `_handle_restart_dpworldapp` | `systemctl restart dpworldapp` |
| `/api/firmware/upload` | `_FW.upload()` | 펌웨어 ZIP을 스테이징 디렉토리로 스트리밍 |
| `/api/firmware/preflight` | `_FW.preflight()` | 플래시 전 사전 점검 |
| `/api/firmware/flash` | `_FW.flash()` | 8단계 OTA 플래시 실행 |
| `/api/firmware/restore-check` | `_FW.restore_check()` | 재부팅 후 설정 무결성 검사 |
### 라우팅 가드
- `/setting/` 접두사: 알 수 없는 경로 → 404 (SPA로 폴스루 없음)
- `/api/` 접두사: 알 수 없는 경로 → 404 (v1.5.0.1 수정, `server.py` do_GET)
- 정적 파일: `os.realpath()` + 접두사 검사 (경로 순회 차단, `server.py:237-239`)
- SPA 폴백: 나머지 모든 GET 경로에 `index.html` 반환
### 정적 파일 서빙 (v1.5.4)
```python
# server.py:224 _serve_static_file()
ETag = W/"mtime-size" # weak ETag (저비용, 해시 없음)
If-None-Match == ETag → 304 # 0바이트 body
Cache-Control:
text/html → no-cache, must-revalidate
JS/CSS → no-cache, must-revalidate (v1.5.4.2 H1+M2: 즉시 배포 전파)
font/image → public, max-age=86400
gzip:
Accept-Encoding q값 파싱 (v1.5.4.2 L1)
압축 대상: text/*, application/javascript, application/json, image/svg+xml
제외: .woff2, 이미지, 256바이트 미만
레벨: 6, Vary: Accept-Encoding 헤더 추가
```
Body 크기 제한: `MAX_BODY_SIZE = 1 MB` (펌웨어 업로드는 예외 — 소켓 타임아웃 None).
---
## 7. 프론트엔드 아키텍처 (v1.5.0 IA 재설계)
### 진입점
`src/static/index.html` — lang="en", title="IoT Web Configurator", 9개 `modulepreload` 힌트
(app / state / api / utils / icons / constants / page-dirty / nav-guard / home).
### 사이드바 레이아웃 (5그룹, 11개 활성 리프)
```
sidebar (aside[role=navigation])
├── Dashboard
│ └── home
├── Network
│ ├── wifi (Wi-Fi SSID + 연결 + 지역)
│ ├── ethernet (이더넷 IP + LTE 인터페이스)
│ └── server-setting (TIOT/Update/RTCM/LTE 서버 엔드포인트)
├── Interface & Protocol
│ ├── general-settings (Equipment + Protocol 선택 + Odometer)
│ ├── sensor-io (RS485 + Analog/Digital 포트)
│ ├── can-bus (CAN 설정 + 프레임 매핑)
│ ├── opcua (OPC UA 엔드포인트 + 필드 매핑)
│ └── modbus (Modbus 엔드포인트 + 바이트 순서 + 필드 매핑)
├── Log
│ └── log (3탭: Configuration / Files / Kernel)
└── Firmware
└── firmware (8단계 OTA 스테퍼)
```
그룹 펼침 상태는 `localStorage("wc.sidebar.groups")`에 유지된다.
`app.js PAGES`에 레거시 별칭 보존 (구 북마크 하위 호환):
- `ssid``wifi`
- `io``ethernet`
- `network``server-setting`
- `register``general` (= `general-settings` 페이지)
- `can``can-bus`
### 페이지 객체 패턴
모든 페이지 모듈은 기본 페이지 객체를 export한다:
```js
export default {
render(container) { ... }, // HTML을 container에 주입
mount(container) { ... }, // 이벤트 리스너 연결, collector 등록
destroy() { ... }, // 리스너 정리 (선택)
validate() { ... }, // 클라이언트 측 유효성 검사 (선택)
}
```
구현 대상: `app.js`, `state.js`, 11개 리프 페이지 전체.
### 상태 관리 (state.js)
- `state.device` — 평탄화된 device config 객체 (JS 인메모리)
- `state.protocol` — 평탄화된 protocol config 객체
- `state.pageDirty` — 페이지별 dirty 매트릭스 (10개 항목):
`{home, wifi, ethernet, server-setting, general, sensor-io, can-bus, opcua, modbus, log, firmware: false}`
- `state.isDirty` — 불리언 하위 호환 별칭 (모든 pageDirty 값의 OR)
- `convertFlatToNested(flat)` / `convertNestedToFlat(nested)` — 레거시 앱의 평탄화 표현과 일치하는 중첩 ↔ 평탄 키 변환
### Page Dirty + Nav Guard
`src/static/js/page-dirty.js`:
- `markDirty(pageId)` / `clearDirty(pageId)` / `hasDirty()` / `getDirtyPages()` / `clearAllDirty()`
- 사이드바 점 (`.nav-item__dirty`)은 모든 호출 시 자동 동기화.
`src/static/js/nav-guard.js`:
- `confirmNavigation(currentPageId)` → Promise 모달 (Save / Discard / Cancel)
- 포커스 트랩 (모달 내부 Tab/Shift+Tab 순환) — v1.5.2 H12 + H13
- Esc 키 = Cancel
- 닫을 때 트리거 포커스 복원 (WCAG 2.4.3) — v1.5.2 H13
### 아이콘 (icons.js)
- 49개 Lucide v0.460+ SVG body 문자열 (ISC 라이선스)
- `icon(name, opts)` 헬퍼: `opts.cls` (클래스, `<>"'` 제거), `opts.aria` (aria-label, `escapeAttr`)
- `escapeAttr`는 모듈 수준에서 정의 (성능 최적화, 호출별 생성 아님)
- 모든 아이콘에 `role="img"` + `<title>` 적용 (WCAG 4.1.2)
- 장식용 아이콘에는 `aria-hidden="true"` 적용
### 보안 (프론트엔드)
- `utils.js``escapeHtml`: `& → &amp;` / `< → &lt;` / `> → &gt;` / `" → &quot;` / `' → &#39;`
— 5문자 명시 치환 (v1.5.2 H1 루트 XSS 수정)
- 운영자 입력값(레지스터 테이블, odometer 필드, import 데이터) 모두 DOM 삽입 전 `escapeHtml` 통과 — v1.4.6.2 F1 + C5 + C6
- icons.js `icon()`의 cls/aria: `escapeAttr``<>"'` 제거
- nav-guard.js `_escape``escapeHtml`을 미러링
- DEBUG console.log: `state.device` 내용 마스킹 (값 대신 키만 표시) — v1.4.6.9 M6
---
## 8. Firmware OTA (v1.5.0 Phase 4a + v1.5.2 하드닝)
### 소스
`C:\Users\F1304\Downloads\dpw-fw-update-tool\webconfig_fw\`에서 `MERGE.md` + `DESIGN.md` (2026-06-06)에 따라 병합.
### 백엔드 패키지 (`src/firmware/`)
| 모듈 | 라인 수 | 역할 |
|---|---|---|
| `fw_client.py` | ~110 | dpw-fw-update-tool 데몬용 Wire 클라이언트 (host:port) |
| `staging.py` | ~47 | ZIP 스테이징: 압축 해제, 유효성 검사, 슬롯 감지 |
| `config_safety.py` | ~172 | 플래시 전 설정 백업, 재부팅 후 복원 검증 |
| `protocol.py` | ~64 | Wire 프로토콜 저수준 프레이밍 |
| `fw_controller.py` | ~480 | 상태 기반 OTA 오케스트레이션, 상태 스키마, 컴포넌트별 진행 바, 재부팅 watchdog |
| `fw_routes.py` | ~110 | 전송 계층 독립적 HTTP 라우트 핸들러, `RouteError` 예외 타입 |
`server.py:94`에서 초기화:
```python
_FW = FirmwareRoutes(FirmwareController(
fw_host=os.environ.get("FW_HOST", "127.0.0.1"),
fw_port=int(os.environ.get("FW_PORT", "8990")),
staging_dir=os.environ.get("FW_STAGING_DIR", "/opt/fw_staging"),
backups_dir=os.environ.get("FW_BACKUPS_DIR", "/opt/config_backups"),
db_path=DB_PATH,
))
```
### 8단계 스테퍼
```
Upload → Verify → Pre-flight → Backup → Flash → Commit → Reboot → Config check
```
### 상태 스키마 (UI의 단일 진실 공급원)
```json
{
"state": "idle|staged|flashing|rebooting|verifying|done|failed",
"phases": [{ "key", "label", "state": "pending|active|done|error", "tier", "detail" }],
"components": [{ "role", "signature", "name", "size", "sha256",
"sent", "pct", "state": "pending|active|done|error" }],
"overall": { "sent", "total", "pct", "elapsed_s" },
"active_signature": "RTF|null",
"backup": { "path", "when" } ,
"slot": { "before": "A|B|?", "after": "A|B|?|null" },
"message": "human line",
"error": null
}
```
### v1.5.2 Firmware 하드닝
| 항목 | 수정 내용 |
|---|---|
| C1 ProtectSystem | 서비스 유닛 + deploy.ps1에 `/opt/fw_staging` + `/opt/config_backups``ReadWritePaths` 추가, 디렉토리 사전 생성 |
| H4 stage_zip TOCTOU | `fw_controller.stage_zip``self._lock` 하에서 'staging' 상태 검사 |
| H19 default_file | `FirmwareController` default_file 오버라이드를 `server.py`에서 제거 |
| H20 upload timeout | `/api/firmware/upload` 소켓 타임아웃을 연결별로 `None`으로 재설정 |
| M10/M15 recover | `_recover_staging`을 백그라운드 데몬 스레드로 실행 (시작 비차단) |
---
## 9. 보안 모델
보안 모델은 레거시 Java 앱 기준선에 맞춰 인증 없는 LAN 전용으로 명시적으로 설계되었다.
| 제어 항목 | 구현 방식 |
|---|---|
| 인증 없음 | 의도적 설계 (LAN 전용 IoT, Java 앱과 동일) |
| CORS | `Access-Control-Allow-Origin: *` (전체 origin 허용, 의도적) |
| HTTP만 사용 | TLS 미적용 (LAN 환경) |
| XSS — 출력 | `utils.js``escapeHtml` 5문자 치환 (v1.5.2 H1 루트 수정) |
| XSS — 속성 | `icons.js``escapeAttr`, 모든 숫자 속성에 숫자 강제 변환 `Number()` (v1.4.6.9 C5/C6) |
| 경로 순회 | `os.realpath()` + `STATIC_DIR` 접두사 검사 (`server.py:237-239`) |
| SQL 인젝션 | `DBManager.ALLOWED_KEYS` 화이트리스트 (`db_manager.py:42`) — CLI 백엔드는 키를 파라미터화 |
| Body 크기 | `MAX_BODY_SIZE = 1 MB` (`server.py:161`) |
| Slowloris | `socket.setdefaulttimeout(30.0)` (`server.py`, v1.5.1) |
| enum 인젝션 | 유효하지 않은 enum 값 시 HTTP 400 하드 거부 (`config_validator.py`) |
| Super_Relay 키 | POST에 `security_config`/`transport_config` 포함 시 HTTP 400 거부 (`v1.4.4`) |
| systemd 하드닝 | `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp`, `ReadWritePaths` 화이트리스트 |
| 지원 번들 | WiFi 비밀번호는 포함 전 마스킹 처리 |
---
## 10. 성능 (v1.5.4 + v1.5.4.2)
### 정적 파일 전달 (LAN, .54 측정값)
| 지표 | 값 |
|---|---|
| 최초 로드 전송량 | ~575 KB → ~200 KB (gzip으로 ~65% 감소) |
| 반복 방문 | ETag/304, 0바이트 body — 거의 즉각 렌더링 |
| 배포 전파 | 즉시 (JS/CSS no-cache, v1.5.4.2 H1+M2 수정) |
| 정적 파일 레이턴시 | 20–32 ms (LAN 왕복) |
| system-status 콜드 | ~6.09 s (journalctl + dpworldapp 로그 스캔) |
| system-status 웜 | ~1.15 s (모듈 캐시) |
### 적용된 기법
- **gzip**: 레벨 6, text/JS/CSS/JSON/SVG, `≥256B`, q값 인식 Accept-Encoding (v1.5.4.2 L1)
- **ETag**: `W/"mtime-size"` 약한 ETag, 파일별, `If-None-Match` → 304
- **Cache-Control**: HTML/JS/CSS `no-cache, must-revalidate`; 폰트/이미지 `max-age=86400`
- **modulepreload**: 9개 핵심 모듈 (`index.html` 링크 힌트) — app.js 파싱 전 병렬 fetch
- **셀프 호스팅 폰트**: 7개 `.woff2` 파일 (Inter 5웨이트 + JetBrains Mono 2웨이트, latin 서브셋, 총 ~200 KB)
— 외부 CDN 의존 없음 (LAN 전용 IoT에서 필수, v1.5.1.1)
- **amss.bin에 mmap 사용**: 전체 읽기 대신 `mmap.find()` — 피크 RSS 1 MB 미만 유지 (v1.4.0)
---
## 11. 동시성 모델
```
Thread A (request) Thread B (request) Daemon (auto-compress)
│ │ │
_lock.acquire() blocks _lock.acquire() (after A)
BEGIN IMMEDIATE ──────── SQLite WAL ────────── (reads log_config)
SELECT + mutator()
INSERT OR REPLACE
COMMIT
_lock.release() ─────── unblocks B
```
- `DBManager._lock``threading.Lock` — 하나의 Python 객체, 프로세스 로컬.
- SQLite 측의 `BEGIN IMMEDIATE`는 DB 레벨에서 동시 쓰기를 차단한다
(WAL 모드는 동시 읽기 허용).
- `fw_controller` 상태 머신은 별도 인스턴스의 `self._lock`으로 보호.
- Nav-guard 이중 모달 레이스: 빠른 클릭 시 시각적 문제만 발생 (기록됨, 허용 수준).
---
## 12. 테스트 인프라 (v1.5.3 듀얼 러너)
### Python pytest (644개 테스트)
`tests/`에 버전/기능별로 구성:
| 패턴 | 설명 |
|---|---|
| Grep 테스트 | 코드 패턴 존재/부재 검증 (대다수 테스트) |
| Behavioral 테스트 | server.py 통합 (FakeDB), config_validator 하드 거부 |
| Multi-instance | `test_v1_5_3_multi_instance.py` — DBManager BEGIN IMMEDIATE 레이스 (10+20 스레드) |
| Schema drift | `test_schema_drift.py``scripts/audit_schema_drift.py``tests/fixtures/java_schema_expected.json` |
실행: `python -m pytest tests/ -v`
### Node 18+ jsdom (35개 테스트, 4개 파일)
| 파일 | 테스트 수 | 검증 내용 |
|---|---|---|
| `tests/test_nav_guard_behavior.mjs` | 9 | 포커스 트랩 Tab/Shift+Tab, Esc, Save/Discard/Cancel, pageId XSS |
| `tests/test_icons_svg.mjs` | 10 | SVG 정합성, viewBox/width/height, aria role=img + title, XSS 저항성 |
| `tests/test_log_tabs_switching.mjs` | 4 | 탭 클릭 → panel.hidden 토글, aria-selected, 기본 상태 |
| `tests/test_save_all_modal.mjs` | 9 | 모달 role/aria, dirty 페이지 목록, Cancel/Confirm promise, escapeHtml XSS |
| `tests/test_firmware_logic.mjs` | 3 | (기존) firmware-logic.js 헬퍼 |
실행: `npm test` (Node 18+ 필요, 최초 1회 `npm install`)
**디바이스 배포 격리**: `deploy.ps1``git archive HEAD src` 사용 — `src/`만 배포됨. `tests/`, `node_modules/`, `package.json`, `package-lock.json`은 디바이스로 전송되지 않는다.
---
## 13. 배포 파이프라인 (scripts/deploy.ps1)
### 단계
```
1. git archive HEAD src → 로컬 tar
2. SSH: mkdir -p $AppDir /opt/fw_staging /opt/config_backups
3. scp tar → 디바이스
4. SSH: tar --warning=no-timestamp -xf tar → _deploy_tmp/
5. SSH: 기존 src/ 백업 → backups/src-$ts (최근 3개 유지)
6. SSH: atomic mv _deploy_tmp/ → src/
7. SSH: /lib/systemd/system/web-configurator.service 설치 (영속)
8. SSH: systemctl daemon-reload + enable + restart web-configurator
9. Confirm-Health: /api/health 최대 30초 폴링
10. 실패 시 자동 롤백: backups/src-$ts 복원 + restart
```
### 주요 플래그
| 플래그 | 효과 |
|---|---|
| (기본값) | 클린 git 워킹 트리 + 버전 태그 필요 |
| `-AllowUntagged` | 태그 요건 생략 (dev/test 배포) |
| `-Rollback` | 가장 최근 backup/src-* 복원 |
### 배포 후 디바이스 상태
- `$AppDir``DEPLOYED_VERSION` 파일 (버전 문자열)
- `$AppDir``deploy-history.log` (배포마다 타임스탬프 + 버전)
- `/lib/systemd/system/web-configurator.service`의 systemd 유닛
### 버전 확인 (배포 후)
```powershell
ssh root@192.168.55.54 "curl -s http://localhost:9090/api/health"
# → {"status":"ok","version":"v1.5.4.2","uptime_s":...}
```
---
## 14. 운영 리뷰 커버리지
### v1.5.2 멀티에이전트 리뷰 (wf_f4786747-995)
- 12개 차원, 318개 에이전트, 약 102건 제기 → **41건 확정**
- 수정: Critical 1 + High 20 + Medium 7 = 28개 항목
- 이연: High 5 (behavioral, jsdom) → v1.5.3
- 방법: 발견 사항별 3-lens 적대 검증 (정확성 / 보안 / 재현성)
— 다수 반박 (2/3) = false positive, 폐기
### v1.5.4.2 집중 리뷰 (wf_a4efa2e3-2cb)
- 6개 차원, 21건 제기 → **5건 확정**
- 수정: H1+M2 Cache-Control JS/CSS, M1 sensor-io placeholder, L1 gzip q-value, L2 focus-visible
- 잔여: Medium 9 + Low 4 (사용자 체감 영향 0)
---
## 15. v1.5.x 릴리즈 타임라인
| 태그 | 커밋 | 핵심 변경 | 테스트 수 |
|---|---|---|---|
| v1.5.0-phase-1 | `c6ec698` | 인프라: 사이드바 5그룹 + icons.js + page-dirty + nav-guard + Firmware placeholder | 481 |
| v1.5.0-phase-2 | `1effda8` | Network 3-리프: wifi + ethernet + server-setting | 500 |
| v1.5.0-phase-3 | `6bdc2a7` | Interface & Protocol 5-리프: general + sensor-io + can-bus + opcua + modbus | 523 |
| v1.5.0-phase-4a | (sha) | Firmware OTA 실제 병합 (webconfig_fw → src/firmware/ + 5개 라우트 + firmware.js 902줄) | 557 |
| v1.5.0 | `b3776a1` | Phase 4b: 이모지 → Lucide SVG ~80개 인스턴스 + log 3탭 + Save All 확인 모달 | 562 |
| v1.5.0.1 | `2b74ad2` | 핫픽스: deploy.ps1 최초 배포 mkdir + /api/ 404 가드 | 564 |
| v1.5.1 | `1c3ffc2` | DBManager.update_config BEGIN IMMEDIATE RMW + slowloris 30초 타임아웃 | 569 |
| v1.5.1.1 | `73e188d` | Inter + JetBrains Mono .woff2 7개 폰트 셀프 호스팅 | 572 |
| v1.5.2 | `6b7047d` | 운영 리뷰 번들: 41건 확정 수정 (C1 ProtectSystem + H1 XSS + H17 systemd + H18 rollback + ...) | 599 |
| v1.5.3 | `3b04e36` | Behavioral 테스트 인프라: jsdom + node:test, 4개 .mjs 파일, 35개 Node 테스트 | Python 615 + Node 35 |
| v1.5.3.1 | (sha) | 핫픽스: analog_input_level enum 5V/10V/20mA → 2/4/6 (Java 회귀) + sensor-io UI 카드 | 621 |
| v1.5.3.2 | (sha) | 핫픽스: modbus.js two_byte_order 누락 (Java TwoByte 회귀) | 625 |
| v1.5.4 | (sha) | 성능: gzip + ETag/304 + Cache-Control + modulepreload (13개 테스트) | 638 |
| v1.5.4.1 | (sha) | 핫픽스: 잔여 이모지 → SVG (1개 테스트) | 639 |
| v1.5.4.2 | `fe5cd25` | 핫픽스 집중 리뷰: Cache-Control no-cache JS/CSS + sensor-io placeholder + gzip q-value + focus-visible (5개 테스트) | 644 |
---
## 16. 컴포넌트 다이어그램 (ASCII)
### 최상위 시스템 구성
```
Operator Browser (LAN)
├──:9090 (direct)──► Python web-configurator.service
│ │
│ server.py (ThreadedHTTPServer)
│ │
│ ┌─────┼──────────────────────────────┐
│ │ │ │
│ DBManager firmware/ system_status.py
│ (sqlite3) fw_controller.py kernel_log.py
│ │ │ log_manager.py
│ │ /opt/fw_staging support_bundle.py
│ │ /opt/config_backups config_validator.py
│ │ dpworldapp_enums.py
│ │ enum_normalizer.py
│ ~/db/dynamic_data.db migrations.py
│ │
│ ┌────────┼────────────┐
│ board_config schema_meta event_history
│ │
│ ┌──────┼──────────┐
│ device_ protocol_ log_
│ config config config
├──:80──► nginx ──:8080──► Java app-runner (legacy, .56 only)
│ │
│ ~/db/dynamic_data.db (same file!)
└── (no browser)──► dpworldapp (/usr/bin/dpworldapp)
~/db/dynamic_data.db (reads device_config + protocol_config)
/opt/log/dpworldapp/*.log (writes)
```
### 프론트엔드 모듈 그래프
```
index.html
└── app.js (type=module, entry point)
├── state.js (device/protocol 상태 + flat↔nested)
├── api.js (모든 REST 엔드포인트 fetch 래퍼)
├── utils.js (escapeHtml, debounce)
├── icons.js (49개 Lucide SVG + icon() 헬퍼)
├── constants.js (APP_VERSION, DEFAULTS, enum 집합)
├── page-dirty.js (markDirty/clearDirty/사이드바 점)
├── nav-guard.js (confirmNavigation 모달 + 포커스 트랩)
├── toast.js (토스트 알림)
├── validator.js (클라이언트 측 필드 유효성 검사)
├── components/
│ ├── crud-table.js (레지스터 필드 매핑 테이블)
│ └── ip-input.js (IP 주소 입력 위젯)
└── pages/
├── home.js (Dashboard)
├── wifi.js (Wi-Fi 설정)
├── ethernet.js (이더넷 + LTE 인터페이스)
├── server-setting.js (서버 엔드포인트)
├── general-settings.js (Equipment + Protocol + Odometer)
├── sensor-io.js (RS485 + Analog/Digital 포트)
├── can-bus.js (CAN 설정 + 프레임 매핑)
├── opcua.js (OPC UA 엔드포인트 + 매핑)
├── modbus.js (Modbus 엔드포인트 + 바이트 순서 + 매핑)
├── log.js (로그 관리 3탭)
└── firmware.js (Firmware OTA 8단계 스테퍼)
[legacy aliases: ssid.js / io.js / network.js / register.js / can.js]
```
### Partial-Merge 데이터 흐름 (POST /setting/device)
```
Browser POST {partial JSON}
server.py:_handle_post_device
_read_request_body() ← 1 MB 제한; dict 가드 (배열/스칼라 거부)
normalize_device_input() ← enum_normalizer.py (case alias: ON→on 등)
validate_wifi_country_code() ← 유효하지 않은 2자 코드 시 하드 거부 400
validate_rs485_integers() ← bool/string 타입 시 하드 거부 400
validate_device_port_types_hard() ← 유효하지 않은 포트 값 시 하드 거부 400
validate_super_relay_keys() ← security/transport_config 포함 시 하드 거부 400
db.update_config("device_config", mutator)
├── BEGIN IMMEDIATE (SQLite 쓰기 잠금)
├── SELECT 기존 데이터
├── 기존 데이터 + POST body 병합 (partial-merge)
├── log_config 키 분리 → db.update_config("log_config", ...)
├── INSERT OR REPLACE device_config
└── COMMIT
응답: {success, merged_keys, preserved_keys_count}
```
---
## 17. 운영 런북
### .54 배포 (dev/verify)
```powershell
cd C:\Development\NEW_Web_Configurator
.\scripts\deploy.ps1 192.168.55.54 -AllowUntagged
```
### .54 태그 배포 (릴리즈)
```powershell
git tag v1.5.4.2 -m "v1.5.4.2"
.\scripts\deploy.ps1 192.168.55.54
```
### 롤백
```powershell
.\scripts\deploy.ps1 192.168.55.54 -Rollback
```
### 수동 서비스 재시작 (deploy.ps1 restart가 조용히 실패할 때)
```powershell
ssh root@192.168.55.54 "systemctl restart web-configurator"
```
### 배포 헬스 확인
```powershell
ssh root@192.168.55.54 "curl -s http://localhost:9090/api/health"
# 예상 결과: {"status":"ok","version":"v1.5.4.2",...}
```
### DB 점검
```powershell
ssh root@192.168.55.54 "sqlite3 /home/root/db/dynamic_data.db 'SELECT key, length(value) FROM board_config;'"
# schema_meta 마이그레이션 점검
ssh root@192.168.55.54 "sqlite3 /home/root/db/dynamic_data.db 'SELECT key, value, applied_at FROM schema_meta;'"
```
### 펌웨어 업로드 (OTA)
```powershell
curl -F "file=@firmware.zip" http://192.168.55.54:9090/api/firmware/upload
curl -s http://192.168.55.54:9090/api/firmware/preflight
curl -X POST http://192.168.55.54:9090/api/firmware/flash
```
### 스키마 드리프트 확인 (로컬)
```powershell
python scripts/audit_schema_drift.py
# Exit 0 = 정상, 2 = 드리프트 감지, 1 = 오류
```
### 전체 테스트 실행
```powershell
python -m pytest tests/ -v # Python: 644개 테스트
npm test # Node: 35개 jsdom 테스트
```
---
## 18. 알려진 제한 사항 및 백로그
| 항목 | 상태 | 비고 |
|---|---|---|
| system-status 콜드 레이턴시 ~6초 | v1.5.5 후보 | 모든 콜드 호출 시 journalctl + 전체 로그 스캔 |
| Java 측 wipe 경로 | 미해결 (Java는 수정 불가) | 레거시 앱이 알 수 없는 enum 값을 조용히 null 처리하고, null 필드를 쓰기 시 삭제 — Python 하드 거부가 1차 방어선 |
| main 브랜치 FF 머지 보류 | 사용자 결정 사항 | .56 운영 환경은 명시적 사용자 승인 전까지 v1.4.6.10 유지 |
| GNSS 스캐너 최신 로그만 처리 | 낮은 우선순위 | 로그 회전 후 dpworldapp 재시작 시 GNSS 티어가 'na' 표시 — 다음 dpworldapp 시작 시 자가 복구 |
| collectAllPagesData 비대칭 | v1.5.5 후보 | Device Save → 3행 쓰기, Register Save → 1행 — 구조적 재설계 필요 |
| 리뷰에서 Medium 9 + Low 4 | 이연 | 사용자 체감 영향 0 — 기각 또는 v1.5.5+ 처리 |
| 16개 페이지 동적 import | v1.6+ 후보 | 현재 모든 페이지 즉시 로드; 동적 import 적용 시 초기 파싱 감소 가능 |
---
## 부록 A — 파일 트리 (src/)
```
src/
├── server.py ← HTTP 서버, 라우팅, 진입점
├── db_manager.py ← SQLite 3티어 + update_config atomic RMW
├── config_validator.py ← 서버 측 유효성 검사 + 정규화
├── dpworldapp_enums.py ← device 계약 enum 값 (단일 진실 공급원)
├── enum_normalizer.py ← Case-only alias 정규화 (ON→on 등)
├── migrations.py ← 시작 마이그레이션 (3개 적용)
├── log_manager.py ← 로그 목록/다운로드/압축/삭제/통계 + auto-compress 데몬
├── kernel_log.py ← journalctl + 커널 로그 번들 빌더
├── system_status.py ← 홈 대시보드 상태 집계
├── support_bundle.py ← 진단 ZIP 생성기
├── firmware/
│ ├── __init__.py
│ ├── fw_client.py ← dpw-fw-update-tool 데몬용 Wire 클라이언트
│ ├── staging.py ← ZIP 스테이징 및 유효성 검사
│ ├── config_safety.py ← 설정 백업/복원
│ ├── protocol.py ← Wire 프로토콜 프레이밍
│ ├── fw_controller.py ← 상태 기반 OTA 오케스트레이션
│ └── fw_routes.py ← HTTP 라우트 핸들러
└── static/
├── index.html ← SPA 진입점, 5그룹 사이드바, modulepreload 힌트
├── css/style.css ← 디자인 토큰 + 컴포넌트 스타일 + firmware CSS
├── fonts/ ← Inter (.woff2 ×5) + JetBrains Mono (.woff2 ×2)
├── img/ ← dp-world-logo.svg
└── js/
├── app.js ← 라우터, Save All, Import/Export, 사이드바 JS
├── api.js ← REST 클라이언트 (모든 fetch 래퍼)
├── state.js ← 인메모리 상태 + flat↔nested 변환
├── page-dirty.js ← 페이지별 dirty 매트릭스 + 사이드바 점
├── nav-guard.js ← 네비게이션 확인 모달 + 포커스 트랩
├── icons.js ← Lucide v0.460+ 49개 SVG + icon() 헬퍼
├── constants.js ← APP_VERSION + DEFAULTS + enum 집합
├── utils.js ← escapeHtml + 헬퍼
├── toast.js ← 토스트 알림
├── validator.js ← 클라이언트 측 필드 유효성 검사
├── country-codes.js ← Wi-Fi 국가 코드 205개 드롭다운용
├── firmware-logic.js ← Firmware OTA 순수 로직 헬퍼
├── components/
│ ├── crud-table.js
│ └── ip-input.js
└── pages/
├── home.js (활성)
├── wifi.js (활성)
├── ethernet.js (활성)
├── server-setting.js (활성)
├── general-settings.js (활성)
├── sensor-io.js (활성)
├── can-bus.js (활성)
├── opcua.js (활성)
├── modbus.js (활성)
├── log.js (활성)
├── firmware.js (활성)
├── ssid.js (레거시 별칭 → wifi)
├── io.js (레거시 별칭 → ethernet, RS485/CAN 잔재)
├── network.js (레거시 별칭 → server-setting)
├── register.js (레거시 별칭 → general-settings)
└── can.js (레거시 별칭 → can-bus)
```
---
## 부록 B — 주요 코드 위치
| 내용 | 파일 | 라인/함수 |
|---|---|---|
| 진입점 / main() | `src/server.py` | `main()` (파일 하단) |
| HTTP 핸들러 클래스 | `src/server.py` | `class ConfigHandler` |
| 정적 파일 서빙 | `src/server.py` | `_serve_static_file()` ~L224 |
| gzip + ETag 로직 | `src/server.py` | `_serve_static_file()` ~L254-299 |
| Partial-merge POST device | `src/server.py` | `_handle_post_device()` |
| Partial-merge POST protocol | `src/server.py` | `_handle_post_protocol()` |
| Firmware 라우트 | `src/server.py` | do_GET + do_POST firmware 분기 |
| Atomic RMW | `src/db_manager.py` | `update_config()` L226 |
| BEGIN IMMEDIATE | `src/db_manager.py` | `update_config()` L258 |
| Java enum 값 | `src/dpworldapp_enums.py` | `DEVICE_ENUM_VALUES` L16, `PROTOCOL_ENUM_VALUES` L39 |
| Case alias 정규화 | `src/enum_normalizer.py` | `normalize_device_input()`, `normalize_protocol_input()` |
| 마이그레이션 | `src/migrations.py` | `apply_all_migrations()` |
| escapeHtml (5문자) | `src/static/js/utils.js` | `escapeHtml()` |
| icon() 헬퍼 | `src/static/js/icons.js` | `icon()` |
| page-dirty 매트릭스 | `src/static/js/state.js` | `state.pageDirty` |
| nav-guard 모달 | `src/static/js/nav-guard.js` | `confirmNavigation()` |
| flat↔nested 변환 | `src/static/js/state.js` | `convertFlatToNested()`, `convertNestedToFlat()` |
| FirmwareController | `src/firmware/fw_controller.py` | `class FirmwareController` |
| Schema drift 테스트 | `tests/test_schema_drift.py` | 파일 전체 |
| Config 계약 스냅샷 | `tests/fixtures/java_schema_expected.json` | 파일 전체 |
| 감사 스크립트 | `scripts/audit_schema_drift.py` | 파일 전체 |
| jsdom nav-guard 테스트 | `tests/test_nav_guard_behavior.mjs` | 파일 전체 |
---
## 부록 C — 관련 문서
| 문서 | 위치 | 내용 |
|---|---|---|
| 구 아키텍처 (v2.2) | `docs/architecture.md` | v1.3.0 시대, 현재 이 문서로 대체됨 |
| 배포 런북 | `docs/DEPLOY.md` | 배포 절차, 버전 관리 |
| 2026-05-29 인시던트 | 내부 인시던트 기록(이 배포물에 미포함) | 전체 행 replace wipe 인시던트 |
| 변경 이력 | `CHANGELOG.md` | v1.4.0 → v1.5.4.2 전체 릴리즈 이력 |