설예인 1 month ago
parent
commit
b546811d1f
  1. 26
      BSP-INTEGRATION.md
  2. 27
      CHANGELOG.md
  3. 17
      DELIVERABLE-MANIFEST.txt
  4. 10
      README.md
  5. 59
      RELEASE-NOTES.md
  6. 2
      VERSION
  7. 25
      deploy/nginx.conf
  8. 10
      deploy/web-configurator.service
  9. 16
      docs/DEPLOY.md
  10. 2
      docs/architecture.md
  11. 168
      docs/deploy-integration-guide.html
  12. 126
      docs/deploy-integration-guide.md
  13. 12
      docs/network-apply-engine-guide.md
  14. 5
      scripts/deploy.ps1
  15. 7
      src/config_validator.py
  16. 3
      src/db_manager.py
  17. 7
      src/dpworldapp_telemetry.py
  18. 11
      src/firmware/fw_routes.py
  19. 2
      src/firmware/protocol.py
  20. 91
      src/log_manager.py
  21. 97
      src/network/ap_engine.py
  22. 20
      src/network/ap_routes.py
  23. 213
      src/network/apply_engine.py
  24. 46
      src/network/net_routes.py
  25. 2
      src/network/netmodel.py
  26. 5
      src/network/renderer.py
  27. 381
      src/network/uplink.py
  28. 32
      src/network/validator.py
  29. 46
      src/network/verifier.py
  30. 56
      src/network/watchdog.py
  31. 15
      src/server.py
  32. 70
      src/static/css/style.css
  33. 7
      src/static/index.html
  34. 42
      src/static/js/api.js
  35. 26
      src/static/js/app.js
  36. 9
      src/static/js/components/crud-table.js
  37. 6
      src/static/js/confirm-modal.js
  38. 3
      src/static/js/constants.js
  39. 1
      src/static/js/icons.js
  40. 11
      src/static/js/nav-guard.js
  41. 6
      src/static/js/pages/firmware.js
  42. 20
      src/static/js/pages/home.js
  43. 19
      src/static/js/pages/log.js
  44. 207
      src/static/js/pages/network.js
  45. 4
      src/static/js/pages/register.js
  46. 2
      src/static/js/pages/sensor-io.js
  47. 458
      src/static/js/pages/ssid.js
  48. 347
      src/static/js/pages/uplink.js
  49. 40
      src/static/js/pages/wifi-ap.js
  50. 8
      src/static/js/state.js
  51. 19
      src/support_bundle.py
  52. 11
      src/system_status.py

26
BSP-INTEGRATION.md

@ -1,4 +1,4 @@
# BSP 통합 가이드 — Web Configurator v1.11.16
# BSP 통합 가이드 — Web Configurator v1.12.1
디바이스 이미지(Yocto/OE 등)에 포함하기 위한 **커밋 히스토리 없는 클린 스냅샷**입니다.
런타임 앱·systemd 유닛·샘플·문서와 optional nginx sample 설정만 들어 있고, 개발/내부 자료와 VCS 이력은 없습니다.
@ -7,7 +7,7 @@
## 1. 런타임 구조
- **Python 3 애플리케이션, stdlib 전용** — pip·virtualenv·외부 패키지 없음.
- 실행 진입점: `python3 /opt/web-configurator/src/server.py`
- 실행 진입점: `python3 /usr/lib/web-configurator/src/server.py`
- 리슨 포트: **9090** (유닛의 `Environment=PORT=` 로 변경 가능)
- 앱은 기본적으로 `:9090`에서 직접 서비스됩니다. nginx `:80` reverse proxy는 optional이며, `deploy/nginx.conf`는 BSP가 필요할 때 쓰는 sample 설정입니다.
- 영속 상태 — **패키지에 없음**(런타임/다른 컴포넌트가 생성):
@ -21,7 +21,7 @@
## 2. 설치 경로 (`do_install`)
| 소스 (이 패키지) | 디바이스 설치 위치 |
|---|---|
| `src/` | `/opt/web-configurator/src/` |
| `src/` | `${libdir}/web-configurator/src/` (`${libdir}` = `/usr/lib`) |
| `deploy/web-configurator.service` | `${systemd_system_unitdir}/` |
| `deploy/dpworld-*.service` | `${systemd_system_unitdir}/` (제품이 사용하는 유닛만) |
| `deploy/*.service.d/` | `${systemd_system_unitdir}/<unit>.service.d/` |
@ -35,9 +35,11 @@
- Wi-Fi AP 사용 시: `deploy/dpworld-ap-apply.service`, `deploy/dpworld-ap-seed.service`, `deploy/dpworld-hostapd-ap0.service`, `deploy/dpworld-udhcpd-ap0.service`, `deploy/dpworld-ap-apply.sh`를 함께 설치
- 복구 유닛 사용 시: `deploy/dpworld-net-recover.service` 설치
- `*.sh``0755` 실행 권한으로 `/usr/bin/`에 설치
> 앱 코드 디렉토리 `/usr/lib/web-configurator``do_install` 이 이미지에 굽는 **read-only program code** 이므로 아래 쓰기 디렉토리 목록에 포함하지 않습니다.
쓰기 가능 런타임 디렉토리 생성 (recipe `do_install` / tmpfiles.d / 유닛의 `ExecStartPre` 중 택1):
```
/opt/web-configurator /opt/log/dpworldapp /opt/fw_staging /opt/fw_upload
/opt/log/dpworldapp /opt/fw_staging /opt/fw_upload
/opt/config_backups /home/root/db /home/root/network
```
@ -76,18 +78,18 @@ SYSTEMD_SERVICE:${PN} = "web-configurator.service \
---
## 5. 샘플 레시피 — `web-configurator_1.11.16.bb`
## 5. 샘플 레시피 — `web-configurator_1.12.1.bb`
```bitbake
SUMMARY = "DP World Smart Solutions — Web Configurator"
LICENSE = "CLOSED"
# 방법 A — 내부 git 서버의 태그 릴리스를 fetch (배포물이 repo 루트에 평탄 배치됨):
SRC_URI = "git://<internal-host>/<path>/NewWebConfigurator.git;branch=<branch>;protocol=ssh"
SRCREV = "<v1.11.16 sha 또는 tag>"
SRCREV = "<v1.12.1 sha 또는 tag>"
S = "${WORKDIR}/git"
# 방법 B — git archive 로 만든 스냅샷 tarball 사용:
# (git -C <repo> archive v1.11.16 | gzip > web-configurator-v1.11.16.tar.gz)
# SRC_URI = "file://web-configurator-v1.11.16.tar.gz"
# (git -C <repo> archive v1.12.1 | gzip > web-configurator-v1.12.1.tar.gz)
# SRC_URI = "file://web-configurator-v1.12.1.tar.gz"
# S = "${WORKDIR}"
inherit systemd
@ -104,9 +106,9 @@ SYSTEMD_SERVICE:${PN} = "web-configurator.service"
SYSTEMD_AUTO_ENABLE = "enable"
do_install() {
# 애플리케이션
install -d ${D}/opt/web-configurator
cp -r ${S}/src ${D}/opt/web-configurator/
# 애플리케이션 (read-only program code → ${libdir} = /usr/lib)
install -d ${D}${libdir}/web-configurator
cp -r ${S}/src ${D}${libdir}/web-configurator/
# systemd 유닛
install -d ${D}${systemd_system_unitdir}
@ -150,7 +152,7 @@ do_install() {
${D}/opt/config_backups ${D}/home/root/db ${D}/home/root/network
}
FILES:${PN} += "/opt/web-configurator /opt/log ${systemd_system_unitdir} \
FILES:${PN} += "${libdir}/web-configurator /opt/log ${systemd_system_unitdir} \
/opt/fw_staging /opt/fw_upload /opt/config_backups \
/home/root/db /home/root/network ${bindir}/dpworld-*.sh \
${sysconfdir}/nginx"

27
CHANGELOG.md

@ -4,6 +4,33 @@
---
## [v1.12.1] — 2026-06-29
### Added
- **텔레메트리 Uplink (Ethernet 전환)**: dpworldapp 클라우드 텔레메트리(프로토콜·업데이트·RTCM) 송신을 웹 UI 에서 Wi-Fi↔Ethernet 전환. 호스트 라우트(/32)만 사용 — dpworldapp·OS 설정 무접촉. 워치독 자가복원 + 관리 인터페이스 보호.
- **라우팅 투명성**: 서버별 실제 송신 인터페이스 표시 + 인터페이스 선택의 영향(로컬 LAN 가림 등) 적용 전 경고(읽기 전용).
### Fixed
- **전체 소스 리뷰 41건 반영**(정직성·안전성·구조): 점검 전 상태의 거짓 정상표시 제거, 설정 로드 실패 시 빈 값 저장 차단, 로그 자동정리 마지막 아카이브 과삭제 방지, 중복 경로/보안 로직 통합 등. **dpworldapp 공유 DB(device/protocol 설정)·계약·타입은 무변경.**
### Note
- 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`)는 v1.12.0 그대로 유지.
## [v1.12.0] — 2026-06-25
### Changed
- **앱 코드 설치 경로 이전**: `/opt/web-configurator``/usr/lib/web-configurator` (협력사 BSP
베이킹 정합 — FHS상 `/usr/lib` 가 read-only program code 자리). 쓰기 런타임 데이터는 그대로 유지:
로그 `/opt/log/dpworldapp`, 펌웨어 `/opt/fw_staging`·`/opt/fw_upload`, 설정 백업 `/opt/config_backups`.
- `web-configurator.service`: `ExecStart`·`WorkingDirectory` 를 새 경로로 변경. 코드 디렉토리는
서비스가 직접 쓰지 않으므로 `ReadWritePaths` 에서 제거(`ProtectSystem=strict` 하 read-only 로 충분).
- `BSP-INTEGRATION.md` 레시피: 코드 설치를 `${libdir}/web-configurator`(=`/usr/lib`) 로 변경,
쓰기 디렉토리 목록에서 코드 디렉토리 제외. `scripts/deploy.ps1` `$AppDir` 동일 이전.
- **기본 포트 통일 8080→9090**: `server.py` 코드 기본 포트를 9090 으로 변경(systemd 유닛값과 동일).
설정기는 PC 개발·디바이스 모두 `:9090` 단일 포트. 디바이스의 `:8080` 은 별개의 레거시 Java
app-runner 임을 문서에서 명확화(README/DEPLOY/network-apply-engine-guide). 디바이스 동작은
무변경(유닛이 이미 `PORT=9090` 지정) — bare/PC 실행 시 기본값만 9090 으로 바뀜.
> 앱 로직·UI·디바이스 계약은 무변경. 설치 경로(배포 계약) 이전 + 포트 정돈. 이전 버전: v1.11.16.
---
## [v1.11.16] — 2026-06-23
### Changed
- `web-configurator.service``MemoryMax`**48M → 128M** 로 상향. support-bundle(진단 zip, 메모리 빌드)

17
DELIVERABLE-MANIFEST.txt

@ -1,7 +1,7 @@
DP World Smart Solutions — Web Configurator
파트너 전달물 — v1.11.16
파트너 전달물 — v1.12.1
BSP 이미지 포함용 클린 스냅샷. 커밋 히스토리 없음, 내부 전용 자료 없음.
일자: 2026-06-23
일자: 2026-06-29
================================================================================
이 패키지는 무엇인가
@ -24,8 +24,12 @@ src/ 애플리케이션 (Python 3, stdlib 전용)
enum_normalizer.py 입력 별칭 정규화
migrations.py 부팅 시 1회 DB 마이그레이션 (schema_meta 게이트, 멱등)
db_manager.py SQLite(board_config) 접근
network/ firmware/ network-apply / 펌웨어 OTA 서브시스템
network/ network-apply 서브시스템
uplink.py [v1.12.1 신규] 텔레메트리 Uplink — 호스트 라우트(/32) 관리 + 워치독
firmware/ 펌웨어 OTA 서브시스템
static/ 웹 UI (HTML/CSS/JS)
js/pages/uplink.js [v1.12.1 신규] 텔레메트리 Uplink UI (인터페이스 전환 + 라우팅 투명성)
[v1.12.1 전체 소스 리뷰 41건 안정화: 정직성·안전성·구조 개선. dpworldapp 공유 DB·계약·타입 무변경]
deploy/ 디바이스 통합 산출물
web-configurator.service 메인 systemd 유닛 (python3 src/server.py, :9090)
nginx.conf optional reverse proxy sample (:80 → 앱)
@ -40,7 +44,7 @@ README.md CHANGELOG.md VERSION RELEASE-NOTES.md BSP-INTEGRATION.md DELIVERA
================================================================================
설치 위치 (디바이스) — 샘플 recipe는 BSP-INTEGRATION.md 참조
================================================================================
src/ → /opt/web-configurator/src/
src/ → /usr/lib/web-configurator/src/ (${libdir}=/usr/lib)
deploy/web-configurator.service → systemd 유닛 디렉토리
deploy/dpworld-*.service + *.d/ → systemd 유닛 디렉토리 (제품이 사용하는 것만)
deploy/dpworld-*.sh / *.conf → 각 유닛 ExecStart 가 가리키는 경로
@ -54,8 +58,9 @@ enable: web-configurator.service (+ 필요한 dpworld-* 유닛)
리슨 : PORT 9090 (유닛 env로 변경 가능)
DB : /home/root/db/dynamic_data.db (env DB_PATH)
로그 : /opt/log/dpworldapp (env LOG_DIR)
쓰기 경로 : /opt/web-configurator /opt/log/dpworldapp /opt/fw_staging
/opt/fw_upload /opt/config_backups /home/root/db /home/root/network
쓰기 경로 : /opt/log/dpworldapp /opt/fw_staging /opt/fw_upload
/opt/config_backups /home/root/db /home/root/network
(앱 코드 /usr/lib/web-configurator 는 read-only — 쓰기 경로 아님)
메모리 상한 : MemoryMax=128M (유닛에 설정됨; v1.11.16에서 48M→128M 상향)
nginx optional: deploy/nginx.conf 는 :80 reverse proxy가 필요한 BSP용 sample입니다.

10
README.md

@ -30,18 +30,18 @@ SQLite 데이터베이스(`board_config` 테이블)에 저장됩니다.
python src/server.py
```
서버는 기본적으로 `0.0.0.0:8080`에 바인딩됩니다 (로컬 개발용). 환경 변수로 재정의할 수 있습니다:
서버는 기본적으로 `0.0.0.0:9090`에 바인딩됩니다. 환경 변수로 재정의할 수 있습니다:
| 변수 | 기본값 | 설명 |
|---|---|---|
| `HOST` | `0.0.0.0` | 바인딩 주소 |
| `PORT` | `8080` | 수신 포트 |
| `PORT` | `9090` | 수신 포트 |
| `DB_PATH` | `/home/root/db/dynamic_data.db` | SQLite 데이터베이스 경로 |
| `LOG_DIR` | `/opt/log/dpworldapp` | 로그 파일 디렉터리 |
> **포트 안내:** `8080`은 로컬 개발 환경에서만 사용하는 기본값입니다. 디바이스에서는
> systemd 유닛(`deploy/web-configurator.service`)이 `PORT=9090`으로 설정하므로,
> 배포된 configurator는 **9090** 포트에서 수신합니다 — [docs/DEPLOY.md](docs/DEPLOY.md) §7 참조.
> **포트 안내:** 설정기는 **PC·디바이스 어디서든 `9090`** 입니다 (디바이스는 systemd 유닛
> `deploy/web-configurator.service``PORT=9090` 을 지정 — 코드 기본값과 동일). 디바이스의
> `:8080`**별개의 레거시 Java app-runner**(이 앱 아님)이며 무관합니다 — [docs/DEPLOY.md](docs/DEPLOY.md) §7 참조.
예시:

59
RELEASE-NOTES.md

@ -1,36 +1,37 @@
# 릴리스 노트 — Web Configurator **v1.11.16**
# 릴리스 노트 — Web Configurator **v1.12.1**
- **배포일**: 2026-06-23
- **배포일**: 2026-06-29
- **대상**: 디바이스 BSP 이미지 포함용 (DP World Smart Solutions · IoT 디바이스)
- **패키지**: 내부 git 태그 `v1.11.16` (커밋 히스토리가 없는 **클린 스냅샷**)
- **이전 버전**: v1.11.15
- **패키지**: 내부 git 태그 `v1.12.1` (커밋 히스토리가 없는 **클린 스냅샷**)
- **이전 버전**: v1.12.0
---
## 1. 한눈에 보기
이번 릴리스는 **배포 패키지 정비 릴리스**입니다. 앱 기능·디바이스 계약·UI는 v1.11.15와 동일하며,
다음 두 가지를 정비했습니다.
1. **MemoryMax 상향** (`48M → 128M`) — 운영 중 OOM-kill 위험 제거.
2. **BSP 통합 가이드 정비** — 네트워크-적용 하드닝 스크립트의 권장 통합 방식을
**"펌웨어 원본 스크립트 직접 교체(BSP 베이킹)"** 를 1차 방식으로 재구성.
- Python 3 **표준 라이브러리(stdlib)만** 사용 — 외부 pip 패키지 없음
- systemd 유닛 포함, nginx 설정은 optional sample → **이미지에 바로 포함 가능**
- 기존 디바이스 DB는 **부팅 시 자동 변환**(1회, 멱등) — 수동 작업 불필요
이번 릴리스는 **텔레메트리 Uplink(Ethernet 전환) 기능 추가 + 전체 안정화** 릴리스입니다.
- **신규 — 텔레메트리 Uplink**: dpworldapp 의 클라우드 텔레메트리(프로토콜·업데이트·RTCM 서버) 송신 경로를
웹 UI 에서 **Wi-Fi ↔ Ethernet 으로 전환**합니다. OS 라우팅(호스트 라우트)만 사용하며, dpworldapp 과
OS 네트워크 설정 파일은 **건드리지 않습니다**.
- **신규 — 라우팅 투명성**: 서버별 실제 송신 인터페이스를 표시하고, 인터페이스 선택이 로컬 LAN 호스트를
가리는 등 충돌 가능성이 있으면 **적용 전에 경고**합니다(읽기 전용 — 포워딩/NAT 를 열지 않음).
- **안정화 — 전체 소스 리뷰 41건 반영**: 정직성(점검되지 않은 상태를 정상으로 표시하던 경로 제거),
안전성(일부 실패 상황에서 설정이 빈 값으로 덮어써지던 경로 차단, 로그 아카이브 과삭제 방지),
구조(중복 로직 통합) 개선. **dpworldapp 공유 DB(device/protocol 설정)·원자적 쓰기 계약은 무변경.**
- v1.12.0 의 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`)는 **그대로 유지**됩니다.
- Python 3 **표준 라이브러리(stdlib)만** 사용 — 외부 pip 패키지 없음.
---
## 2. 주요 변경 (v1.11.15 → v1.11.16)
## 2. 주요 변경 (v1.12.0 → v1.12.1)
| 항목 | 변경 |
|---|---|
| **MemoryMax** | `web-configurator.service``MemoryMax`**48M → 128M** 로 상향. 48M는 support-bundle(진단 zip, 메모리 빌드)과 UI 동시 사용 시 실측 피크 ~40-50MB로 OOM-kill 위험이 있었음. 128M = 실피크 ~2.5x 여유 + 누수 방지 캡 유지 (장비 RAM 3.4GB 대비 3.7%). |
| **BSP 통합 방식 1차 = 직접 교체** | 네트워크-적용 하드닝본을 **`/usr/bin/dpworld-network-apply.sh` 원본 이름으로 교체 설치**하는 것을 BSP 베이킹의 기본 방식으로 명시. `.service.d` override 드롭인은 **라이브 디바이스 후적용(over-the-top) fallback** 으로 강등. (자세히 `BSP-INTEGRATION.md` §7 + `docs/firmware-boot-hardening.md`) |
| 패키지 구성 정리 | 배포물을 repo 루트(flat)로 정리, 내부 전용 참조/링크 정돈. 런타임 코드 변경 없음. |
| **텔레메트리 Uplink (신규)** | dpworldapp 클라우드 텔레메트리 송신을 웹 UI 에서 Wi-Fi↔Ethernet 전환. 선택한 인터페이스의 게이트웨이를 통해 대상 서버 IP 에 대한 **호스트 라우트(/32)** 를 설치하는 방식 — relay·dpworldapp·OS 네트워크 파일 무변경. 적용은 비동기로 즉시 반환하고, 워치독이 라우트 유실 시 **자가복원**합니다. 관리 인터페이스가 끊기지 않도록 안전장치를 포함합니다. |
| **라우팅 투명성 + 사전 경고 (신규)** | 서버별 라우팅 상태와 실제 송신 인터페이스를 표시하고, 인터페이스 선택의 영향(로컬 LAN 호스트 가림 등)을 **적용 전에 경고**. 읽기 전용 — 포워딩/NAT 를 열지 않습니다. |
| **전체 소스 리뷰 41건 반영 (안정화)** | 정직성·안전성·구조 개선. 대표적으로: 점검 전 상태를 정상으로 표시하던 대시보드 경로 수정, 설정 로드 실패 시 빈 값 저장으로 인한 설정 손실 차단, 로그 자동정리의 마지막 아카이브 과삭제 방지, 중복된 경로/보안 로직 통합. **dpworldapp 공유 DB·계약·타입은 무변경.** |
> 앱 코드 자체는 **v1.11.15와 동일**합니다 (UI/기능 무변경). 본 릴리스는 배포 단위의
> 메모리 정책 + 통합 가이드 정비입니다.
> v1.12.0 에서 이전한 설치 경로(`/usr/lib`)·기본 포트(`:9090`)는 본 릴리스에서도 유지됩니다.
---
@ -58,6 +59,9 @@
---
## 4. 호환성 · 주의사항
- **텔레메트리 Uplink**: 인터페이스 전환은 **호스트 라우트(/32)** 만 설치/제거하며 dpworldapp·OS 설정
파일을 변경하지 않습니다. 전환을 즉시 반영하려면 dpworldapp 재시작 또는 디바이스 재기동이 필요할 수
있습니다(기존 TCP 연결은 전환 시점에 자동 이전되지 않음).
- **디바이스 적용 시점**: dpworldapp 재시작 시 새 계약값으로 config 가 재적용됩니다.
- **nginx optional**: `deploy/nginx.conf`는 BSP에서 `:80` reverse proxy를 제공할 때 쓰는 sample입니다. `scripts/deploy.ps1`는 nginx를 설치/수정하지 않으며, 디바이스가 앱을 `:9090`으로 직접 접근하면 없어도 됩니다. sample을 쓸 경우 `proxy_pass`를 앱 포트 `:9090`으로 맞추세요.
- **런타임 의존성**: python3 stdlib만 필요 (RDEPENDS 목록은 `BSP-INTEGRATION.md` 참조). 외부 패키지 없음.
@ -66,9 +70,9 @@
---
## 5. 배포 방법 (BSP)
1. **패키지 입수**: 내부 git 태그 `v1.11.16` (또는 동봉 스냅샷)
1. **패키지 입수**: 내부 git 태그 `v1.12.1` (또는 동봉 스냅샷)
2. **레시피 작성**: `BSP-INTEGRATION.md`의 샘플 bitbake recipe + 설치 경로표 사용
- `src/``/opt/web-configurator/src/`
- `src/``${libdir}/web-configurator/src/` (`${libdir}` = `/usr/lib`)
- `deploy/*.service` → systemd 유닛 디렉토리
- `deploy/dpworld-network-apply-hardened.sh`**`/usr/bin/dpworld-network-apply.sh`** (펌웨어 원본 교체, §3 ①)
- `deploy/nginx.conf` → optional nginx sample site config
@ -78,15 +82,16 @@
---
## 6. 품질/검증
- v1.11.16 변경은 **앱 로직 무변경** — systemd 서비스 메모리 캡(MemoryMax) + 문서/주석/버전 문자열뿐입니다.
기능 동등 기준선 **v1.11.15**는 내부 테스트 스위트(Python + 프런트 `.mjs`)로 검증되었습니다.
- 본 릴리스는 내부 테스트 스위트(Python + 프런트 `.mjs`)로 검증되었으며, 텔레메트리 Uplink 기능과
41 건의 리뷰 수정이 모두 회귀 테스트를 통과했습니다.
- 텔레메트리 Uplink·라우팅 투명성은 운영 디바이스에서 동작 확인되었습니다(dpworldapp·공유 설정 무접촉).
- 테스트 스위트는 내부 저장소에서 관리되며 **본 배포물에는 포함되지 않습니다**.
- 패키지는 **커밋 히스토리 없음**(`git archive` 스냅샷) + 내부 분석/개발 자료 제외 (`DELIVERABLE-MANIFEST.txt` 참조).
---
## 7. 롤백
- BSP recipe의 버전 핀(태그/SRCREV)을 **v1.11.15**로 되돌리면 이전 버전 배포.
- BSP recipe의 버전 핀(태그/SRCREV)을 **v1.12.0**으로 되돌리면 이전 버전 배포.
- 마이그레이션은 **멱등 + 변경 전 자동 백업**(config_safety)으로 보호됩니다.
---
@ -94,7 +99,9 @@
## 버전 이력 (요약)
| 버전 | 일자 | 요약 |
|---|---|---|
| **v1.11.16** | 2026-06-23 | MemoryMax 48M→128M + BSP 베이킹(직접 교체) 가이드 1차 정비 (앱 기능 무변경) |
| **v1.12.1** | 2026-06-29 | 텔레메트리 Uplink(Ethernet 전환) + 라우팅 투명성 추가 + 전체 소스 리뷰 41건 안정화 (설치 경로·포트는 v1.12.0 유지) |
| v1.12.0 | 2026-06-25 | 앱 코드 설치 경로 `/opt/web-configurator``/usr/lib/web-configurator` 이전 + 기본 포트 8080→9090 (앱 기능 무변경) |
| v1.11.16 | 2026-06-23 | MemoryMax 48M→128M + BSP 베이킹(직접 교체) 가이드 1차 정비 |
| v1.11.15 | 2026-06-23 | 사이드바 System 그룹 monitor 아이콘 정리 |
| v1.11.14 | 2026-06-23 | 사이드바 부모 그룹(System=Dashboard+Log / Firmware) 재편 |
| v1.11.11 | 2026-06-23 | config 쓰기 경로를 디바이스 config-reader 계약에 정렬 + 자동 마이그레이션 |

2
VERSION

@ -1 +1 @@
v1.11.16
v1.12.1

25
deploy/nginx.conf

@ -1,25 +0,0 @@
server {
listen 80;
server_name _;
# Static files
location / {
root /opt/web-configurator/static;
try_files $uri $uri/ /index.html;
expires 1h;
add_header Cache-Control "public, must-revalidate";
}
location /configuration/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
location = /configuration {
return 301 /configuration/;
}
}

10
deploy/web-configurator.service

@ -4,6 +4,10 @@
# v1.11.16: MemoryMax 48M→128M. 48M was tight — support-bundle(io.BytesIO)+UI 동시
# 사용 시 실측 피크 ~40-50MB로 OOM-kill 위험. 128M = 실피크 ~2.5x 여유 + 누수 방지
# 캡 유지(장비 RAM 3.4GB 대비 3.7%). 정적자산 최대 93KB·업로드는 디스크 스트리밍.
# v1.12.0: 앱 코드 디렉토리 /opt/web-configurator → /usr/lib/web-configurator (협력사 BSP
# 베이킹 정합 — FHS상 /usr/lib 가 read-only program code 자리). 쓰기 런타임(/opt/log,
# /opt/fw_*, /opt/config_backups)은 /opt 유지. 코드 디렉토리는 서비스가 직접 쓰지 않으므로
# ReadWritePaths 에서 제거(ProtectSystem=strict 하 read-only 로 충분).
[Unit]
Description=IoT Web Configurator
# v1.6.0 C3: wpa_supplicant@wlan0 이후 기동 — watchdog 첫 틱에 /var/run/wpa_supplicant
@ -15,8 +19,8 @@ StartLimitBurst=5
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/web-configurator/src/server.py
WorkingDirectory=/opt/web-configurator/src
ExecStart=/usr/bin/python3 /usr/lib/web-configurator/src/server.py
WorkingDirectory=/usr/lib/web-configurator/src
Restart=always
RestartSec=5
Environment=DB_PATH=/home/root/db/dynamic_data.db
@ -37,7 +41,7 @@ ProtectHome=read-only
# /tmp symlink 공격면 일부 복원 — 폐쇄 LAN 단일 운영자 장비라 수용 (CHANGELOG 기록).
PrivateTmp=no
PrivateDevices=yes
ReadWritePaths=/home/root/db /opt/log/dpworldapp /opt/web-configurator /opt/fw_staging /opt/fw_upload -/opt/config_backups -/home/root/network /tmp
ReadWritePaths=/home/root/db /opt/log/dpworldapp /opt/fw_staging /opt/fw_upload -/opt/config_backups -/home/root/network /tmp
# /var/run 은 /run 의 symlink — namespace 는 실경로 기준이므로 둘 다 지정 (.56 실측)
ReadWritePaths=-/var/run/wpa_supplicant -/run/wpa_supplicant
ProtectKernelTunables=yes

16
docs/DEPLOY.md

@ -100,7 +100,7 @@ NEW Web Configurator는 여러 현장의 IoT 디바이스에 배포되는 Python
배포 성공 후, 디바이스에서 실행 중인 내용을 확인합니다:
```bash
ssh root@<ip> cat /opt/web-configurator/DEPLOYED_VERSION
ssh root@<ip> cat /usr/lib/web-configurator/DEPLOYED_VERSION
```
이 파일은 매 배포 시 덮어쓰이며, 다음 내용을 포함합니다:
@ -116,7 +116,7 @@ deployed_by=<user>@<host>
배포 이력을 확인하려면:
```bash
ssh root@<ip> cat /opt/web-configurator/deploy-history.log
ssh root@<ip> cat /usr/lib/web-configurator/deploy-history.log
```
각 줄의 형식: `<timestamp> <version> <commit-short> <deployed_by>`.
@ -139,11 +139,11 @@ ssh root@<ip> cat /opt/web-configurator/deploy-history.log
| 항목 | 값 |
|---|---|
| 앱 디렉터리 | `/opt/web-configurator/` |
| 앱 디렉터리 | `/usr/lib/web-configurator/` |
| systemd 서비스 | `web-configurator.service``src/server.py` 실행 |
| Python 설정 서버 포트 | **9090** |
| Nginx (리버스 프록시) | 포트 **80** |
| Java app-runner | 포트 **8080** |
| Java app-runner (레거시·별개 프로그램) | 포트 **8080** — 이 앱(설정기)과 무관 |
| 디바이스 — 프로덕션(메인) | `192.168.55.56` (유선 `eth1`) |
| 디바이스 — 개발/검증(보조) | `192.168.55.54` |
| Python 런타임 | Python 3.10 |
@ -153,10 +153,10 @@ ssh root@<ip> cat /opt/web-configurator/deploy-history.log
| 경로 | 용도 |
|---|---|
| `/opt/web-configurator/src/` | 실행 중인 애플리케이션 소스 |
| `/opt/web-configurator/backups/` | 디바이스 내 백업 스냅샷(최근 3개) |
| `/opt/web-configurator/DEPLOYED_VERSION` | 현재 버전 기록 파일 |
| `/opt/web-configurator/deploy-history.log` | 배포별 감사 로그 |
| `/usr/lib/web-configurator/src/` | 실행 중인 애플리케이션 소스 |
| `/usr/lib/web-configurator/backups/` | 디바이스 내 백업 스냅샷(최근 3개) |
| `/usr/lib/web-configurator/DEPLOYED_VERSION` | 현재 버전 기록 파일 |
| `/usr/lib/web-configurator/deploy-history.log` | 배포별 감사 로그 |
| `/home/root/db/dynamic_data.db` | SQLite 설정 데이터베이스(`board_config` 테이블) |
| `/opt/log/dpworldapp/` | 애플리케이션 로그 디렉터리 |

2
docs/architecture.md

@ -361,7 +361,7 @@ NEW_Web_Configurator/
├── CHANGELOG.md README.md .gitignore
```
> 디바이스 배포 위치: `/opt/web-configurator/src/` (systemd가 구동, port 9090).
> 디바이스 배포 위치: `/usr/lib/web-configurator/src/` (systemd가 구동, port 9090).
> `deploy/`의 systemd 유닛·셸 스크립트는 rootfs `/lib/systemd` 등에 설치되며 **flash 마다 wipe**
> 된다(§10 caveat, [`wifi-ap-guide.md`](wifi-ap-guide.md) 참조).

168
docs/deploy-integration-guide.html

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Web Configurator — deploy/ 통합 가이드 (v1.12.1)</title>
<style>
:root { --fg:#1c2128; --muted:#57606a; --line:#d0d7de; --bg:#fff; --accent:#0969da;
--callout-bg:#f6f8fa; --callout-bd:#0969da; --warn-bg:#fff8e6; --warn-bd:#bf8700; --code-bg:#eff1f3; }
* { box-sizing:border-box; }
body { margin:0; background:#f4f5f7; color:var(--fg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
line-height:1.6; font-size:15px; }
.wrap { max-width:980px; margin:0 auto; padding:40px 28px 80px; background:var(--bg);
min-height:100vh; box-shadow:0 0 0 1px var(--line); }
h1 { font-size:26px; margin:0 0 6px; }
h2 { font-size:20px; margin:34px 0 12px; padding-top:14px; border-top:1px solid var(--line); }
h3 { font-size:16px; margin:22px 0 8px; }
p { margin:10px 0; }
.meta { color:var(--muted); font-size:13.5px; margin:0 0 8px; padding:10px 14px;
background:var(--callout-bg); border-radius:6px; }
table { border-collapse:collapse; width:100%; margin:12px 0; font-size:14px; }
th, td { border:1px solid var(--line); padding:8px 10px; text-align:left; vertical-align:top; }
th { background:var(--callout-bg); font-weight:600; }
code { background:var(--code-bg); padding:1px 5px; border-radius:4px; font-size:13px;
font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace; }
blockquote { margin:12px 0; padding:10px 16px; background:var(--callout-bg);
border-left:4px solid var(--callout-bd); border-radius:0 6px 6px 0; color:#24292f; }
blockquote.warn { background:var(--warn-bg); border-left-color:var(--warn-bd); }
ul, ol { margin:10px 0; padding-left:24px; }
li { margin:5px 0; }
.footer { margin-top:40px; padding-top:16px; border-top:1px solid var(--line);
color:var(--muted); font-size:13px; }
strong { font-weight:700; }
</style>
</head>
<body>
<div class="wrap">
<h1>Web Configurator — <code>deploy/</code> 통합 가이드 <span style="color:var(--muted);font-size:18px;">(협력사 전달용)</span></h1>
<div class="meta">
<strong>목적</strong>: <code>deploy/</code> 폴더의 구성요소 중 <strong>꼭 필요한 것</strong>과 그 <strong>기능</strong>을 정리하고, 기존 <code>dpworldapp</code>/펌웨어와의 <strong>"충돌" 오해</strong>를 해소합니다.<br>
<strong>대상 버전</strong>: v1.12.1 · <strong>검증</strong>: <code>deploy/</code> 통합 의미론은 v1.11.15 기준 소스 코드 + 실디바이스(192.168.55.54, 펌웨어 원본 상태) 대조 완료. v1.12.0 설치 경로 이전(<code>/usr/lib</code>)과 v1.12.1 <code>nginx.conf</code> 제거를 반영했습니다.
</div>
<h2>0. 한눈에 — 핵심 3가지</h2>
<ol>
<li><strong>웹 설정기 앱 자체는 네트워크/AP 파일 없이도 단독으로 동작합니다</strong> (<code>:9090</code>). 앱은 <code>src/</code>(파이썬) + <code>web-configurator.service</code> 두 가지만 있으면 기동·설정 저장이 됩니다.</li>
<li><strong>"충돌"의 실체는 단 3개 파일</strong>(네트워크 드롭인)이 <em>펌웨어가 소유한 유닛을 덮어쓰는 것</em>뿐입니다. 그 외 모든 파일은 <strong>새로 추가(additive)</strong>되는 것이라 기존 자원을 전혀 건드리지 않습니다 — 크래시·에러를 내지 않습니다.</li>
<li>따라서 <strong>원하는 기능만 골라 설치</strong>하면 충돌 없이 통합됩니다. (§3에 3가지 통합 방식)</li>
</ol>
<blockquote>
<strong>경로 표준 (v1.12.0~, 협력사 협의 반영)</strong>: 앱 코드는 <strong><code>/usr/lib/web-configurator</code></strong> 에 설치합니다 — FHS상 <code>/usr/lib</code> 가 read-only program code 자리이며, BSP가 이미지에 굽는 read-only rootfs에 적합합니다. 쓰기 런타임 데이터(로그·펌웨어 staging/upload·설정 백업)는 <code>/usr/lib</code>(read-only)가 아니라 <strong>쓰기 가능한 영속 파티션(<code>/opt</code>, <code>/home/root</code>)</strong> 에 둡니다(§4).<br><br>
<strong>신규 텔레메트리 Uplink (v1.12.1)</strong> 는 런타임에 OS 호스트 라우트(/32)만 조작하므로 <strong><code>deploy/</code> 추가 구성요소가 필요 없습니다</strong> — 새 유닛/스크립트 없이 기존 <code>web-configurator.service</code>(root 권한) 만으로 동작합니다.
</blockquote>
<h2>1. 기능 그룹과 필요한 파일</h2>
<p><code>deploy/</code>의 구성요소는 <strong>3개 기능 그룹</strong>으로 나뉩니다. 그룹 단위로 켜고 끌 수 있습니다.</p>
<h3>A. 웹 설정기 (필수 — 항상 설치)</h3>
<table>
<thead><tr><th>파일</th><th>기능</th><th>설치 위치</th></tr></thead>
<tbody>
<tr><td>(앱 본체 <code>src/</code>)</td><td>파이썬 stdlib HTTP 서버. 웹 UI + 설정 API. <code>:9090</code> 리슨</td><td><code>/usr/lib/web-configurator/src</code> <em>(표준 — read-only rootfs 가능; 쓰기 데이터는 §4)</em></td></tr>
<tr><td><code>web-configurator.service</code></td><td>위 앱을 부팅 자동기동 + 크래시 시 재시작하는 systemd 유닛</td><td><code>${systemd_system_unitdir}</code></td></tr>
</tbody>
</table>
<p>이 그룹만으로 웹 설정기가 동작합니다(설정은 DB <code>/home/root/db</code>에 저장). <strong><code>dpworldapp</code>과 충돌하지 않습니다.</strong></p>
<h3>B. 네트워크 즉시(라이브) 적용 — 선택</h3>
<blockquote>
<strong>왜 필요한가</strong>: <code>dpworldapp</code><strong>재기동 시에만</strong> 네트워크/설정을 OS에 반영합니다. 이 그룹은 웹 UI에서 IP·Wi-Fi(SSID/비밀번호)를 바꾸면 <strong>재부팅·dpworldapp 재기동 없이 즉시</strong> 반영하기 위한 <strong>보완</strong> 구성입니다.<br><br>
<strong>동작 메커니즘</strong>: 웹이 렌더 파일을 <code>/home/root/network/</code>에 기록 → 적용 스크립트가 그것을 <code>/run/systemd/network/</code>로 동기화 + <code>networkctl</code>/<code>wpa_cli</code> 재구성. (※ <code>networkctl</code><code>/home/root/network</code>를 직접 읽지 않으므로 이 <strong>동기화 스크립트가 있어야</strong> 라이브 적용이 됩니다.)
</blockquote>
<table>
<thead><tr><th>파일</th><th>기능</th><th>설치 위치</th></tr></thead>
<tbody>
<tr><td><code>dpworld-network-apply-hardened.sh</code></td><td>네트워크 적용 스크립트. <strong>펌웨어 원본의 안전 버전</strong> — Wi-Fi country 변경 시 <code>modprobe -r wlan</code>(QCA6490 워치독 리부팅 루프 유발)을 제거하고 country를 <strong>재부팅 시 적용(reboot-deferred)</strong>으로 처리</td><td><code>/usr/bin/</code> (0755)</td></tr>
<tr><td><code>dpworld-net-recover.service</code> <em>(옵션)</em></td><td>wlan 모듈이 완전히 내려갔을 때 복구(<code>modprobe</code> + <code>wpa</code> 재시작). 웹/워치독이 on-demand로 호출</td><td><code>${systemd_system_unitdir}</code></td></tr>
</tbody>
</table>
<blockquote>위 스크립트를 <strong>어떻게</strong> 펌웨어 적용 경로에 연결할지(드롭인 vs 전용 유닛 vs 미사용)는 <strong>§3</strong>에서 선택합니다. 드롭인 3종은 §3 옵션 ③에서만 사용합니다.</blockquote>
<h3>C. Wi-Fi AP 모드 — 선택</h3>
<blockquote><strong>기능</strong>: 장치를 Wi-Fi AP로 띄워 작업자가 휴대폰/노트북으로 직접 접속(현장 provisioning). <code>wlan0</code>(STA)은 건드리지 않고 <strong><code>ap0</code> 가상 인터페이스</strong>를 추가해 사용. hostapd + udhcpd + <strong>ap0 전용 방화벽</strong>(INPUT 전체 개방·WPA2 PSK가 접근 게이트 / FORWARD 차단 = AP 클라이언트의 내부망·PLC(eth1) 경유 차단).</blockquote>
<table>
<thead><tr><th>파일</th><th>기능</th><th>설치 위치</th></tr></thead>
<tbody>
<tr><td><code>dpworld-ap-apply.sh</code></td><td>AP 기동/해제 스크립트(ap0 생성·IP·방화벽·hostapd/udhcpd 시작)</td><td><code>/usr/bin/</code> (0755)</td></tr>
<tr><td><code>dpworld-ap-apply.service</code></td><td>웹이 on-demand로 호출하는 AP 적용 유닛 (<code>[Install]</code> 없음 = 부팅 자동기동 안 함)</td><td><code>${systemd_system_unitdir}</code></td></tr>
<tr><td><code>dpworld-hostapd-ap0.service</code></td><td><code>ap0</code>에서 hostapd 실행 (<code>[Install]</code> 없음, ap-apply.sh가 기동)</td><td><code>${systemd_system_unitdir}</code></td></tr>
<tr><td><code>dpworld-udhcpd-ap0.service</code></td><td><code>ap0</code>에서 DHCP 서버 실행 (<code>[Install]</code> 없음, ap-apply.sh가 기동)</td><td><code>${systemd_system_unitdir}</code></td></tr>
<tr><td><code>dpworld-ap-seed.service</code> <em>(옵션)</em></td><td>재부팅 후 AP를 자동 재기동(복구). <strong>없으면 재부팅 후 AP 수동 재활성 필요</strong></td><td><code>${systemd_system_unitdir}</code></td></tr>
</tbody>
</table>
<blockquote>AP 그룹은 <strong>전적으로 신규 구성</strong>입니다. <code>dpworldapp</code>/펌웨어 자원을 건드리지 않으며, <code>ap0</code> 전용이라 STA·eth·<code>dpworldapp</code>과 충돌하지 않습니다.</blockquote>
<h2>2. 제외 / 참고</h2>
<table>
<thead><tr><th>파일</th><th>사유</th></tr></thead>
<tbody>
<tr><td><code>deploy/nginx.conf</code></td><td><strong>v1.12.1에서 패키지에서 제거됨</strong>. 앱은 <code>:9090</code>에서 직접 서비스되므로 reverse proxy 없이 동작합니다. 협력사가 자체 nginx로 <code>:80</code> 프런트할 경우, 협력사 설정에서 <code>proxy_pass http://127.0.0.1:9090/</code>만 잡으면 됩니다(앱 포트는 <code>:9090</code> — 레거시 <code>:8080</code> Java app-runner 아님).</td></tr>
<tr><td><code>scripts/board_trace*.sh</code></td><td>(<code>deploy/</code> 밖이지만 참고) board_config를 폴링해 <code>/tmp</code>에 기록하는 <strong>개발 진단용</strong> 스크립트 — 운영 배포물에서 제거 권장</td></tr>
</tbody>
</table>
<h2>3. "충돌" 해소 — 네트워크 적용 통합 3가지 방식</h2>
<p><strong>"충돌"은 §1-B의 라이브 적용을 <em>펌웨어 유닛을 빌려서</em> 수행하기 때문에 발생합니다.</strong> 웹은 적용을 <code>dpworld-network-apply.service</code>(펌웨어 소유 유닛) 이름으로 호출하므로, 그 유닛이 우리 스크립트를 실행하게 하려면 유닛을 건드려야 합니다. 아래 3가지 중 선택하세요.</p>
<table>
<thead><tr><th>방식</th><th>설치 파일</th><th>펌웨어 유닛 접촉</th><th>라이브 적용</th><th>비고</th></tr></thead>
<tbody>
<tr><td><strong>① 저장 전용</strong></td><td>네트워크 파일 <strong>0개</strong></td><td><strong>없음(무접촉)</strong></td><td>✗ (dpworldapp 재기동 시 반영)</td><td>웹은 설정 편집·저장만. 충돌 0. 가장 단순</td></tr>
<tr><td><strong>② 전용 유닛 (권장)</strong></td><td><code>dpworld-network-apply-hardened.sh</code> + 신규 <strong>웹 전용 유닛</strong></td><td><strong>없음(무접촉)</strong></td><td></td><td>웹이 펌웨어 유닛 대신 <strong>자체 유닛</strong>으로 적용. <strong>웹 소규모 코드 변경 필요</strong>(적용 유닛명을 <code>NET_APPLY_SERVICE</code> env로 분리 — 현재는 <code>dpworld-network-apply.service</code>로 하드코딩)</td></tr>
<tr><td><strong>③ 드롭인 override (현재 패키지)</strong></td><td><code>dpworld-network-apply-hardened.sh</code> + 드롭인 3종</td><td><strong>있음(override)</strong></td><td></td><td>펌웨어 유닛의 ExecStart/의존성을 우리 것으로 교체. 이것이 협력사가 본 "충돌"</td></tr>
</tbody>
</table>
<p><strong>드롭인 3종(③에서만 사용):</strong></p>
<ul>
<li><code>dpworld-network-apply.service.d/20-hardened.conf</code> — 펌웨어 <code>dpworld-network-apply.service</code><code>ExecStart</code>를 hardened.sh로 교체</li>
<li><code>dpworld-network-seed.service.d/20-hardened.conf</code> — 펌웨어 <code>dpworld-network-seed.service</code>(부팅 seed)를 <code>hardened.sh --boot</code>로 교체</li>
<li><code>dpworld-network-apply-ondemand.conf</code> — 위 유닛의 <code>Requires=</code>(boot-only seed 의존)를 비워 on-demand 기동 허용</li>
</ul>
<blockquote><strong>권장</strong>: 펌웨어 베이킹(BSP)에서는 하드닝본을 <code>/usr/bin/dpworld-network-apply.sh</code> <strong>원본 이름으로 직접 교체</strong>하는 방식이 1차입니다(드롭인 미설치 — <code>BSP-INTEGRATION.md</code> §7 ①). 펌웨어 유닛을 못 건드리는 <strong>라이브 디바이스</strong>에서만 드롭인 override(③)를 fallback으로 씁니다. 라이브 적용이 불필요하면 <strong>① 저장 전용</strong>. (③/직접교체 시 §1-B 동작 차이 — Wi-Fi country는 재부팅 시 적용 — 만 합의하면 됨).</blockquote>
<h2>4. 설치 시 주의 (부분배포 사고 방지)</h2>
<ul>
<li><strong>드롭인(③)을 설치하면 <code>dpworld-network-apply-hardened.sh</code>도 반드시 함께</strong> 설치하세요. 드롭인만 있고 스크립트가 없으면 ExecStart가 없는 파일을 가리켜 <strong>펌웨어 네트워크 유닛이 EXEC 실패</strong>합니다(멀쩡하던 적용이 깨짐).</li>
<li><strong><code>web-configurator.service</code>를 설치하면 앱 본체 <code>src/</code>도 함께</strong> 설치하세요. 앱이 없으면 서비스가 재시작 루프에 빠집니다.</li>
<li><strong>쓰기 가능 경로</strong>: 앱 코드는 read-only rootfs(<code>/usr/lib/web-configurator</code>)에 두지만, 아래 런타임 경로는 <strong>쓰기 가능한 영속 파티션</strong>(<code>/opt</code>, <code>/home/root</code>)에 있어야 합니다.
<ul>
<li>DB <code>/home/root/db</code> · 네트워크 렌더 <code>/home/root/network</code> <em>(dpworldapp과 공유 — 그대로)</em></li>
<li>로그 <code>/opt/log/dpworldapp</code>(유닛 <code>LOG_DIR</code>) · 펌웨어 staging/upload <code>/opt/fw_staging</code>·<code>/opt/fw_upload</code> · 설정 백업 <code>/opt/config_backups</code> <em>(유닛 <code>ReadWritePaths</code> 참조)</em></li>
<li>hardened.sh 상태/로그 <code>STATE_DIR=/opt/dpworld-network</code> <em>(펌웨어 원본엔 없던 신규 경로 — <code>/opt</code>가 늦게 마운트되면 로그/reboot 마커만 유실, 적용 자체는 진행). <code>/opt</code> 마운트 보장(<code>RequiresMountsFor=/opt</code>)을 권장</em></li>
</ul>
</li>
</ul>
<h2>5. 전체 파일 분류표 (요약)</h2>
<table>
<thead><tr><th><code>deploy/</code> 파일</th><th>그룹</th><th>필요성</th><th>펌웨어 유닛 접촉</th></tr></thead>
<tbody>
<tr><td><code>web-configurator.service</code></td><td>A 코어</td><td><strong>필수</strong></td><td>무접촉(신규 유닛)</td></tr>
<tr><td><code>dpworld-network-apply-hardened.sh</code></td><td>B 네트워크</td><td>라이브 적용 시 필요</td><td>무접촉(파일)</td></tr>
<tr><td><code>dpworld-network-apply.service.d/20-hardened.conf</code></td><td>B 네트워크</td><td><strong>③에서만</strong></td><td>★ override</td></tr>
<tr><td><code>dpworld-network-seed.service.d/20-hardened.conf</code></td><td>B 네트워크</td><td><strong>③에서만</strong></td><td>★ override</td></tr>
<tr><td><code>dpworld-network-apply-ondemand.conf</code></td><td>B 네트워크</td><td><strong>③에서만</strong></td><td>★ override</td></tr>
<tr><td><code>dpworld-net-recover.service</code></td><td>B 네트워크</td><td>옵션(복구 안전망)</td><td>무접촉(신규 유닛)</td></tr>
<tr><td><code>dpworld-ap-apply.sh</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(파일)</td></tr>
<tr><td><code>dpworld-ap-apply.service</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(신규 유닛)</td></tr>
<tr><td><code>dpworld-hostapd-ap0.service</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(신규 유닛)</td></tr>
<tr><td><code>dpworld-udhcpd-ap0.service</code></td><td>C AP</td><td>AP 시 필수</td><td>무접촉(신규 유닛)</td></tr>
<tr><td><code>dpworld-ap-seed.service</code></td><td>C AP</td><td>옵션(재부팅 후 AP 자동복구)</td><td>무접촉(신규 유닛)</td></tr>
</tbody>
</table>
<blockquote>★ 표시(드롭인 3종)만이 펌웨어 소유 유닛을 건드립니다 = "충돌"의 전부. 이 3개를 빼면 펌웨어/<code>dpworldapp</code> 자원은 <strong>0개</strong> 건드리지 않습니다.<br><em><code>nginx.conf</code>는 v1.12.1에서 패키지에서 제거됨(§2) — deploy/ 구성요소 아님.</em></blockquote>
<h2>부록: dpworldapp과의 공유·격리 요약</h2>
<ul>
<li><strong>공유(의도된 연동)</strong>: DB <code>/home/root/db</code>, 네트워크 렌더 <code>/home/root/network</code> — 웹과 <code>dpworldapp</code>이 동일 파일 계약(byte 호환)을 공유. 웹이 편집·저장하고 <code>dpworldapp</code>이 소비하는 구조(충돌 아님).</li>
<li><strong>격리(신규, 무접촉)</strong>: 웹 앱(<code>:9090</code>), AP(<code>ap0</code> 전용 + 방화벽), 복구 유닛, 텔레메트리 Uplink(OS 호스트 라우트만) — 전부 신규 자원.</li>
<li><strong>유일한 접점</strong>: <code>dpworld-network-apply.service</code>/<code>-seed.service</code>(펌웨어 소유) — 라이브 적용을 위해 ③ 방식에서만 override. ① 또는 ②를 택하면 이 접점도 사라집니다.</li>
</ul>
<div class="footer">Web Configurator v1.12.1 · <code>deploy/</code> 통합 가이드 · 앱 코드 <code>/usr/lib/web-configurator</code> 표준 · 소스 + 실디바이스(.54) 검증본(통합 의미론)</div>
</div>
</body>
</html>

126
docs/deploy-integration-guide.md

@ -0,0 +1,126 @@
# Web Configurator — `deploy/` 통합 가이드 (협력사 전달용)
> **목적**: `deploy/` 폴더의 구성요소 중 **꼭 필요한 것**과 그 **기능**을 정리하고, 기존 `dpworldapp`/펌웨어와의 **"충돌" 오해**를 해소합니다.
> **대상 버전**: v1.12.1 · **검증**: `deploy/` 통합 의미론은 v1.11.15 기준 소스 코드 + 실디바이스(192.168.55.54, 펌웨어 원본 상태) 대조 완료. v1.12.0 설치 경로 이전(`/usr/lib`)과 v1.12.1 `nginx.conf` 제거를 반영했습니다.
---
## 0. 한눈에 — 핵심 3가지
1. **웹 설정기 앱 자체는 네트워크/AP 파일 없이도 단독으로 동작합니다** (`:9090`). 앱은 `src/`(파이썬) + `web-configurator.service` 두 가지만 있으면 기동·설정 저장이 됩니다.
2. **"충돌"의 실체는 단 3개 파일**(네트워크 드롭인)이 *펌웨어가 소유한 유닛을 덮어쓰는 것*뿐입니다. 그 외 모든 파일은 **새로 추가(additive)**되는 것이라 기존 자원을 전혀 건드리지 않습니다 — 크래시·에러를 내지 않습니다.
3. 따라서 **원하는 기능만 골라 설치**하면 충돌 없이 통합됩니다. (§3에 3가지 통합 방식)
> **경로 표준 (v1.12.0~, 협력사 협의 반영)**: 앱 코드는 **`/usr/lib/web-configurator`** 에 설치합니다 — FHS상 `/usr/lib` 가 read-only program code 자리이며, BSP가 이미지에 굽는 read-only rootfs에 적합합니다. 쓰기 런타임 데이터(로그·펌웨어 staging/upload·설정 백업)는 `/usr/lib`(read-only)가 아니라 **쓰기 가능한 영속 파티션(`/opt`, `/home/root`)** 에 둡니다(§4).
>
> **신규 텔레메트리 Uplink (v1.12.1)** 는 런타임에 OS 호스트 라우트(/32)만 조작하므로 **`deploy/` 추가 구성요소가 필요 없습니다** — 새 유닛/스크립트 없이 기존 `web-configurator.service`(root 권한) 만으로 동작합니다.
---
## 1. 기능 그룹과 필요한 파일
`deploy/`의 구성요소는 **3개 기능 그룹**으로 나뉩니다. 그룹 단위로 켜고 끌 수 있습니다.
### A. 웹 설정기 (필수 — 항상 설치)
| 파일 | 기능 | 설치 위치 |
|---|---|---|
| (앱 본체 `src/`) | 파이썬 stdlib HTTP 서버. 웹 UI + 설정 API. `:9090` 리슨 | `/usr/lib/web-configurator/src` *(표준 — read-only rootfs 가능; 쓰기 데이터는 §4)* |
| `web-configurator.service` | 위 앱을 부팅 자동기동 + 크래시 시 재시작하는 systemd 유닛 | `${systemd_system_unitdir}` |
이 그룹만으로 웹 설정기가 동작합니다(설정은 DB `/home/root/db`에 저장). **`dpworldapp`과 충돌하지 않습니다.**
### B. 네트워크 즉시(라이브) 적용 — 선택
> **왜 필요한가**: `dpworldapp`**재기동 시에만** 네트워크/설정을 OS에 반영합니다. 이 그룹은 웹 UI에서 IP·Wi-Fi(SSID/비밀번호)를 바꾸면 **재부팅·dpworldapp 재기동 없이 즉시** 반영하기 위한 **보완** 구성입니다.
>
> **동작 메커니즘**: 웹이 렌더 파일을 `/home/root/network/`에 기록 → 적용 스크립트가 그것을 `/run/systemd/network/`로 동기화 + `networkctl`/`wpa_cli` 재구성. (※ `networkctl``/home/root/network`를 직접 읽지 않으므로 이 **동기화 스크립트가 있어야** 라이브 적용이 됩니다.)
| 파일 | 기능 | 설치 위치 |
|---|---|---|
| `dpworld-network-apply-hardened.sh` | 네트워크 적용 스크립트. **펌웨어 원본의 안전 버전** — Wi-Fi country 변경 시 `modprobe -r wlan`(QCA6490 워치독 리부팅 루프 유발)을 제거하고 country를 **재부팅 시 적용(reboot-deferred)**으로 처리 | `/usr/bin/` (0755) |
| `dpworld-net-recover.service` *(옵션)* | wlan 모듈이 완전히 내려갔을 때 복구(`modprobe` + `wpa` 재시작). 웹/워치독이 on-demand로 호출 | `${systemd_system_unitdir}` |
> 위 스크립트를 **어떻게** 펌웨어 적용 경로에 연결할지(드롭인 vs 전용 유닛 vs 미사용)는 **§3**에서 선택합니다. 드롭인 3종은 §3 옵션 ③에서만 사용합니다.
### C. Wi-Fi AP 모드 — 선택
> **기능**: 장치를 Wi-Fi AP로 띄워 작업자가 휴대폰/노트북으로 직접 접속(현장 provisioning). `wlan0`(STA)은 건드리지 않고 **`ap0` 가상 인터페이스**를 추가해 사용. hostapd + udhcpd + **ap0 전용 방화벽**(INPUT 전체 개방·WPA2 PSK가 접근 게이트 / FORWARD 차단 = AP 클라이언트의 내부망·PLC(eth1) 경유 차단).
| 파일 | 기능 | 설치 위치 |
|---|---|---|
| `dpworld-ap-apply.sh` | AP 기동/해제 스크립트(ap0 생성·IP·방화벽·hostapd/udhcpd 시작) | `/usr/bin/` (0755) |
| `dpworld-ap-apply.service` | 웹이 on-demand로 호출하는 AP 적용 유닛 (`[Install]` 없음 = 부팅 자동기동 안 함) | `${systemd_system_unitdir}` |
| `dpworld-hostapd-ap0.service` | `ap0`에서 hostapd 실행 (`[Install]` 없음, ap-apply.sh가 기동) | `${systemd_system_unitdir}` |
| `dpworld-udhcpd-ap0.service` | `ap0`에서 DHCP 서버 실행 (`[Install]` 없음, ap-apply.sh가 기동) | `${systemd_system_unitdir}` |
| `dpworld-ap-seed.service` *(옵션)* | 재부팅 후 AP를 자동 재기동(복구). **없으면 재부팅 후 AP 수동 재활성 필요** | `${systemd_system_unitdir}` |
> AP 그룹은 **전적으로 신규 구성**입니다. `dpworldapp`/펌웨어 자원을 건드리지 않으며, `ap0` 전용이라 STA·eth·`dpworldapp`과 충돌하지 않습니다.
---
## 2. 제외 / 참고
| 파일 | 사유 |
|---|---|
| `deploy/nginx.conf` | **v1.12.1에서 패키지에서 제거됨**. 앱은 `:9090`에서 직접 서비스되므로 reverse proxy 없이 동작합니다. 협력사가 자체 nginx로 `:80` 프런트할 경우, 협력사 설정에서 `proxy_pass http://127.0.0.1:9090/`만 잡으면 됩니다(앱 포트는 `:9090` — 레거시 `:8080` Java app-runner 아님). |
| `scripts/board_trace*.sh` | (`deploy/` 밖이지만 참고) board_config를 폴링해 `/tmp`에 기록하는 **개발 진단용** 스크립트 — 운영 배포물에서 제거 권장 |
---
## 3. "충돌" 해소 — 네트워크 적용 통합 3가지 방식
**"충돌"은 §1-B의 라이브 적용을 *펌웨어 유닛을 빌려서* 수행하기 때문에 발생합니다.** 웹은 적용을 `dpworld-network-apply.service`(펌웨어 소유 유닛) 이름으로 호출하므로, 그 유닛이 우리 스크립트를 실행하게 하려면 유닛을 건드려야 합니다. 아래 3가지 중 선택하세요.
| 방식 | 설치 파일 | 펌웨어 유닛 접촉 | 라이브 적용 | 비고 |
|---|---|---|---|---|
| **① 저장 전용** | 네트워크 파일 **0개** | **없음(무접촉)** | ✗ (dpworldapp 재기동 시 반영) | 웹은 설정 편집·저장만. 충돌 0. 가장 단순 |
| **② 전용 유닛 (권장)** | `dpworld-network-apply-hardened.sh` + 신규 **웹 전용 유닛** | **없음(무접촉)** | ✓ | 웹이 펌웨어 유닛 대신 **자체 유닛**으로 적용. **웹 소규모 코드 변경 필요**(적용 유닛명을 `NET_APPLY_SERVICE` env로 분리 — 현재는 `dpworld-network-apply.service`로 하드코딩) |
| **③ 드롭인 override (현재 패키지)** | `dpworld-network-apply-hardened.sh` + 드롭인 3종 | **있음(override)** | ✓ | 펌웨어 유닛의 ExecStart/의존성을 우리 것으로 교체. 이것이 협력사가 본 "충돌" |
**드롭인 3종(③에서만 사용):**
- `dpworld-network-apply.service.d/20-hardened.conf` — 펌웨어 `dpworld-network-apply.service``ExecStart`를 hardened.sh로 교체
- `dpworld-network-seed.service.d/20-hardened.conf` — 펌웨어 `dpworld-network-seed.service`(부팅 seed)를 `hardened.sh --boot`로 교체
- `dpworld-network-apply-ondemand.conf` — 위 유닛의 `Requires=`(boot-only seed 의존)를 비워 on-demand 기동 허용
> **권장**: 펌웨어 베이킹(BSP)에서는 하드닝본을 `/usr/bin/dpworld-network-apply.sh` **원본 이름으로 직접 교체**하는 방식이 1차입니다(드롭인 미설치 — `BSP-INTEGRATION.md` §7 ①). 펌웨어 유닛을 못 건드리는 **라이브 디바이스**에서만 드롭인 override(③)를 fallback으로 씁니다. 라이브 적용이 불필요하면 **① 저장 전용**. (③/직접교체 시 §1-B 동작 차이 — Wi-Fi country는 재부팅 시 적용 — 만 합의하면 됨).
---
## 4. 설치 시 주의 (부분배포 사고 방지)
- **드롭인(③)을 설치하면 `dpworld-network-apply-hardened.sh`도 반드시 함께** 설치하세요. 드롭인만 있고 스크립트가 없으면 ExecStart가 없는 파일을 가리켜 **펌웨어 네트워크 유닛이 EXEC 실패**합니다(멀쩡하던 적용이 깨짐).
- **`web-configurator.service`를 설치하면 앱 본체 `src/`도 함께** 설치하세요. 앱이 없으면 서비스가 재시작 루프에 빠집니다.
- **쓰기 가능 경로**: 앱 코드는 read-only rootfs(`/usr/lib/web-configurator`)에 두지만, 아래 런타임 경로는 **쓰기 가능한 영속 파티션**(`/opt`, `/home/root`)에 있어야 합니다.
- DB `/home/root/db` · 네트워크 렌더 `/home/root/network` *(dpworldapp과 공유 — 그대로)*
- 로그 `/opt/log/dpworldapp`(유닛 `LOG_DIR`) · 펌웨어 staging/upload `/opt/fw_staging`·`/opt/fw_upload` · 설정 백업 `/opt/config_backups` *(유닛 `ReadWritePaths` 참조)*
- hardened.sh 상태/로그 `STATE_DIR=/opt/dpworld-network` *(펌웨어 원본엔 없던 신규 경로 — `/opt`가 늦게 마운트되면 로그/reboot 마커만 유실, 적용 자체는 진행). `/opt` 마운트 보장(`RequiresMountsFor=/opt`)을 권장*
---
## 5. 전체 파일 분류표 (요약)
| `deploy/` 파일 | 그룹 | 필요성 | 펌웨어 유닛 접촉 |
|---|---|---|---|
| `web-configurator.service` | A 코어 | **필수** | 무접촉(신규 유닛) |
| `dpworld-network-apply-hardened.sh` | B 네트워크 | 라이브 적용 시 필요 | 무접촉(파일) |
| `dpworld-network-apply.service.d/20-hardened.conf` | B 네트워크 | **③에서만** | ★ override |
| `dpworld-network-seed.service.d/20-hardened.conf` | B 네트워크 | **③에서만** | ★ override |
| `dpworld-network-apply-ondemand.conf` | B 네트워크 | **③에서만** | ★ override |
| `dpworld-net-recover.service` | B 네트워크 | 옵션(복구 안전망) | 무접촉(신규 유닛) |
| `dpworld-ap-apply.sh` | C AP | AP 시 필수 | 무접촉(파일) |
| `dpworld-ap-apply.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) |
| `dpworld-hostapd-ap0.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) |
| `dpworld-udhcpd-ap0.service` | C AP | AP 시 필수 | 무접촉(신규 유닛) |
| `dpworld-ap-seed.service` | C AP | 옵션(재부팅 후 AP 자동복구) | 무접촉(신규 유닛) |
> ★ 표시(드롭인 3종)만이 펌웨어 소유 유닛을 건드립니다 = "충돌"의 전부. 이 3개를 빼면 펌웨어/`dpworldapp` 자원은 **0개** 건드리지 않습니다.
> *`nginx.conf`는 v1.12.1에서 패키지에서 제거됨(§2) — deploy/ 구성요소 아님.*
---
## 부록: dpworldapp과의 공유·격리 요약
- **공유(의도된 연동)**: DB `/home/root/db`, 네트워크 렌더 `/home/root/network` — 웹과 `dpworldapp`이 동일 파일 계약(byte 호환)을 공유. 웹이 편집·저장하고 `dpworldapp`이 소비하는 구조(충돌 아님).
- **격리(신규, 무접촉)**: 웹 앱(`:9090`), AP(`ap0` 전용 + 방화벽), 복구 유닛, 텔레메트리 Uplink(OS 호스트 라우트만) — 전부 신규 자원.
- **유일한 접점**: `dpworld-network-apply.service`/`-seed.service`(펌웨어 소유) — 라이브 적용을 위해 ③ 방식에서만 override. ① 또는 ②를 택하면 이 접점도 사라집니다.

12
docs/network-apply-engine-guide.md

@ -1160,7 +1160,7 @@ StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
ExecStart=/usr/bin/python3 /opt/web-configurator/src/server.py
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
@ -1175,7 +1175,7 @@ RestartSec=5
|------|---------|------------------|
| `After=`/`Wants=` | `wpa_supplicant@wlan0.service` | watchdog 첫 틱에서 `/var/run/wpa_supplicant` 제어 소켓이 보이도록 기동 순서 보장 — 부팅 race 로 인한 wpa 쿼리 실패 → production WiFi flap 방지([§5.2](#5-watchdog--self-healing--상시-감시자가복구), `web-configurator.service:8-11`) |
| `PrivateTmp=no` | 공유 `/tmp` 사용 | `wpa_cli` 가 응답 수신용 클라이언트 소켓을 `/tmp/wpa_ctrl_<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/web-configurator`, `/opt/fw_staging`, `-/opt/config_backups`, `-/home/root/network`, `/tmp`(`:39`) |
| `ProtectSystem=strict` + `ReadWritePaths` | 화이트리스트 | `strict``/tmp` 까지 RO 로 만들므로 쓰기 경로를 명시해야 함. 화이트리스트: `/home/root/db`, `/opt/log/dpworldapp`, `/opt/fw_staging`, `-/opt/config_backups`, `-/home/root/network`, `/tmp`(`:39`) |
| wpa 제어 소켓 RW | `-/var/run/wpa_supplicant -/run/wpa_supplicant` | `/var/run``/run` 의 symlink — namespace 는 실경로 기준이므로 둘 다 지정(`:40-41`) |
| `MemoryMax=48M` | 메모리 캡 | v1.1.1 안정화 도입. dpworldapp 등 펌웨어 프로세스와의 공존 — amss.bin/펌웨어 MD5 read 시 1MB 청크 강제 등이 이 캡 전제([§5.5](#5-watchdog--self-healing--상시-감시자가복구), `:24`) |
| 추가 샌드박싱 | `NoNewPrivileges`, `ProtectHome=read-only`, `PrivateDevices`, `ProtectKernelTunables/Modules/ControlGroups`, `RestrictSUIDSGID`, `LockPersonality` | OS 레벨 최소비용 sandboxing(`:42-46`) |
@ -1212,7 +1212,7 @@ Requires=
### 9.2 `deploy.ps1` 네트워크 단계
근거: `scripts/deploy.ps1`. 고정 사실: `$AppDir=/opt/web-configurator`, `$Service=web-configurator`, `$Port=9090`, SSH 는 `root@<ip>` + `StrictHostKeyChecking=no -o BatchMode=yes`(`deploy.ps1:31-35`).
근거: `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`).
@ -1224,13 +1224,13 @@ Requires=
4. `10-ondemand.conf` drop-in 설치(`:229-248`): `mkdir -p .../dpworld-network-apply.service.d` 후 hash-compare scp + `daemon-reload`. 디렉토리도 영속 `/lib` 하에 둔다.
5. swap & 검증(`:250-291`): `rm -rf src && mv _deploy_tmp/src src` → 버전 스탬프(`DEPLOYED_VERSION`, `deploy-history.log`) → `systemctl restart` + `Confirm-Health`. health 실패 시 **자동 롤백**(`backups/src-$ts` 복원 + 재시작, `DEPLOYED_VERSION``version=rolled-back` 으로 갱신). src 백업은 최근 3개 유지(`:155-164`).
검증: `ssh root@<ip> cat /opt/web-configurator/DEPLOYED_VERSION`(`:300`).
검증: `ssh root@<ip> cat /usr/lib/web-configurator/DEPLOYED_VERSION`(`:300`).
### 9.3 경로 / 포트 (운영 .56 기준)
| 항목 | 값 | 출처 (env / 기본값) |
|------|-----|---------------------|
| HTTP 포트 | `9090` | `PORT` env(유닛에서 9090 지정; 코드 기본은 8080) — `server.py:79`, `web-configurator.service:23` |
| 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`) |
@ -1283,7 +1283,7 @@ eth1(운영자 접속 인터페이스로 가정)이 변경 대상에 포함되
#### 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 /opt/web-configurator/DEPLOYED_VERSION`.
- 헬스/버전: `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`).

5
scripts/deploy.ps1

@ -28,7 +28,10 @@ param(
$ErrorActionPreference = 'Stop'
# --- Fixed device facts ---------------------------------------------------
$AppDir = '/opt/web-configurator'
# v1.12.0: 앱 코드 디렉토리 /opt/web-configurator → /usr/lib/web-configurator (협력사 BSP
# 베이킹 정합). 이 장비에서 /usr/lib 는 /lib/systemd/system 과 동일한 영속 RW overlay 라
# in-place src swap·backups/·DEPLOYED_VERSION 기록이 그대로 동작한다(쓰기 산출물은 $AppDir 하 유지).
$AppDir = '/usr/lib/web-configurator'
$Service = 'web-configurator'
$Port = 9090
$SshTarget = "root@$DeviceIp"

7
src/config_validator.py

@ -575,12 +575,17 @@ def _validate_register_entry(entry, label, errors, require_addr=False, require_i
# idt: case-insensitive membership (lowercase before check)
idt = entry.get("idt")
if idt is not None and idt != "":
if idt.lower() not in _REGISTER_IDT_VALUES:
if not isinstance(idt, str):
errors.append(f"{label}.idt must be string")
elif idt.lower() not in _REGISTER_IDT_VALUES:
errors.append(f"{label}.idt={idt!r} not in reader idt set")
# odt: accept synonyms float/double→float64, bool→boolean (case-insensitive)
odt = entry.get("odt")
_odt_normalized = None
if odt is not None and odt != "":
if not isinstance(odt, str):
errors.append(f"{label}.odt must be string")
else:
_odt_lower = odt.lower()
if _odt_lower in ("float", "double"):
_odt_normalized = "float64"

3
src/db_manager.py

@ -40,7 +40,8 @@ class DBManager:
"""Configuration storage manager with 3-tier backend."""
# Whitelist of allowed config keys (SQL injection defense for CLI backend)
ALLOWED_KEYS = frozenset({'device_config', 'protocol_config', 'log_config', 'net_config', 'ap_config'}) # AP: ap_config key
ALLOWED_KEYS = frozenset({'device_config', 'protocol_config', 'log_config', 'net_config',
'ap_config', 'uplink_config'}) # uplink: Telemetry Uplink (web-owned)
def __init__(self, db_path=None, backend=None):
self.db_path = db_path or os.environ.get("DB_PATH", DB_PATH_DEFAULT)

7
src/dpworldapp_telemetry.py

@ -7,7 +7,7 @@ dpworldapp 가 `127.0.0.1:8989` 에 LISTEN 하며 connection 직후 헤더 스
version / MAC / IP) 수집한다.
발견 경위 (2026-06-09):
- 6-byte command flow를 TCP telemetry 동작과 비교 검증
- 레거시 6-byte serial command protocol TCP 이식했을 가능성을 탐색
- TCP 포트 inventory 결과 `.54` dpworldapp 8989 + 8990 포트만 listen
(8990 = firmware OTA, 이미 알려진 채널)
- 8989 6-byte ReadAll/GetFwVersion/GetMacAddress 모두 송신 같은 응답
@ -103,7 +103,10 @@ def query_telemetry(host=DEFAULT_HOST, port=DEFAULT_PORT, timeout_s=DEFAULT_TIME
break
buf += chunk
# 모든 헤더 frame 수신했으면 조기 종료 (실시간 stream 안 받음 — CPU/network 절약)
if all(k.encode("ascii") in buf for k in HEADER_KEYS):
# Only check the completed (newline-terminated) portion to avoid
# matching a label whose value is still split across chunks.
completed = buf[:buf.rfind(b'\n') + 1]
if completed and all(k.encode("ascii") in completed for k in HEADER_KEYS):
break
except (socket.error, OSError):
return {}

11
src/firmware/fw_routes.py

@ -34,8 +34,17 @@ class RouteError(Exception):
class FirmwareRoutes:
def __init__(self, controller, tmp_dir=None):
# B14: fail-closed — a missing tmp_dir must never silently fall through
# to gettempdir()/tmpfs. Under MemoryMax=48M with no swap, buffering a
# multi-hundred-MB ZIP on tmpfs OOM-kills the service (v1.10.2 incident).
if not tmp_dir:
raise ValueError(
"tmp_dir is required for FirmwareRoutes; "
"must be a persistent disk path (e.g. /opt/fw_upload), "
"NEVER tmpfs/RAM — see OOM incident v1.10.2"
)
self.fw = controller
self.tmp_dir = tmp_dir or tempfile.gettempdir()
self.tmp_dir = tmp_dir
# ---- GET -----------------------------------------------------------
def status(self):

2
src/firmware/protocol.py

@ -57,7 +57,7 @@ def classify_ack(text):
first so a frame carrying both is treated as success.
"""
upper = text.upper()
if "SUCCESS" in upper or "COMPLETE_ACK" in upper:
if _contains_token(upper, "SUCCESS") or _contains_token(upper, "COMPLETE_ACK"):
return "success"
if "FW_FAIL" in upper or any(_contains_token(upper, t) for t in _FAILURE_TOKENS):
return "failure"

91
src/log_manager.py

@ -75,6 +75,34 @@ def _has_allowed_extension(name):
return bool(_ROTATION_SUFFIX_RE.search(name))
# B12: sentinel returned by _resolve_log_path when the extension check fails.
# Callers use different wording for this case ("File extension not allowed" vs
# "Unsupported extension") so they format the message themselves.
_EXT_DENIED = object()
def _resolve_log_path(name):
"""Perform the 5-step security gate for a single filename.
Returns (resolved_path, None) on success.
Returns (None, _EXT_DENIED) if the extension is not whitelisted callers
must produce their own error message for this case.
Returns (None, error_str) for all other failures.
"""
if not isinstance(name, str) or not name.strip():
return None, "Invalid filename: empty or non-string"
if '..' in name or '/' in name or '\\' in name:
return None, f"Invalid filename: {name}"
if not _has_allowed_extension(name):
return None, _EXT_DENIED
filepath = os.path.realpath(os.path.join(LOG_DIR, name))
if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep):
return None, f"Invalid filename: {name}"
if not os.path.isfile(filepath):
return None, f"File not found: {name}"
return filepath, None
def _make_tar_name(name, mtime):
"""
Archive name for a log file: ``<original name>.<log-mtime>.tar.gz``.
@ -172,26 +200,11 @@ def validate_filenames(filenames):
valid_paths = []
for name in filenames:
if not isinstance(name, str) or not name.strip():
return None, f"Invalid filename: empty or non-string"
# Block path traversal characters
if '..' in name or '/' in name or '\\' in name:
return None, f"Invalid filename: {name}"
# Extension whitelist — block .partial, .sh, etc.
# Also permit uncompressed logrotate suffixes: *.log.0, *.log.1, …
if not _has_allowed_extension(name):
filepath, err = _resolve_log_path(name)
if err is _EXT_DENIED:
return None, f"File extension not allowed: {name}"
# Resolve symlinks and verify it stays within LOG_DIR
filepath = os.path.realpath(os.path.join(LOG_DIR, name))
if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep):
return None, f"Invalid filename: {name}"
if not os.path.isfile(filepath):
return None, f"File not found: {name}"
if err is not None:
return None, err
valid_paths.append(filepath)
# Check total size limit
@ -489,7 +502,7 @@ def _run_cleanup(max_files, max_size_mb):
deleted = 0
total_size = sum(a[1] for a in archives)
while archives and (len(archives) > max_files or total_size > max_size_bytes):
while len(archives) > 1 and (len(archives) > max_files or total_size > max_size_bytes):
entry = archives.pop(0)
mtime, size, name, path = entry
try:
@ -529,6 +542,21 @@ def migrate_log_config(db):
key: device.get(key, LOG_CONFIG_DEFAULTS[key])
for key in LOG_CONFIG_KEYS
}
# B11: clamp integer fields to the same hard floors enforced by
# validate_log_config_hard — a legacy device_config with sub-floor
# values (e.g. log_cleanup_max_size_mb=0) must not be seeded as-is.
_seed_floors = {
"log_cleanup_max_files": 5,
"log_cleanup_max_size_mb": 50,
"log_compress_size_mb": 1,
"log_compress_age_days": 1,
}
for key, floor in _seed_floors.items():
v = seeded.get(key)
try:
seeded[key] = max(floor, int(v))
except (TypeError, ValueError):
seeded[key] = LOG_CONFIG_DEFAULTS[key]
db.save_config("log_config", seeded)
logger.info("Migrated log-management settings into the log_config key")
except Exception as e:
@ -633,27 +661,12 @@ def delete_log_files(filenames):
errors = []
for name in filenames:
if not isinstance(name, str) or not name.strip():
errors.append(f"Invalid filename: empty or non-string")
continue
# Block path traversal
if '..' in name or '/' in name or '\\' in name:
errors.append(f"Invalid filename: {name}")
continue
filepath = os.path.realpath(os.path.join(LOG_DIR, name))
if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep):
errors.append(f"Invalid filename: {name}")
continue
# v1.3.1: enforce extension whitelist — same set used by validate_filenames
if not _has_allowed_extension(name):
filepath, err = _resolve_log_path(name)
if err is _EXT_DENIED:
errors.append(f"Unsupported extension: {name}")
continue
if not os.path.isfile(filepath):
errors.append(f"File not found: {name}")
if err is not None:
errors.append(err)
continue
try:

97
src/network/ap_engine.py

@ -1,6 +1,6 @@
# src/network/ap_engine.py
"""AP apply 오케스트레이션 (spec §7/§8). 렌더 파일 작성 + dpworld-ap-apply.service 트리거 + 상태 persist."""
import json, os, threading
import json, os, re, threading
from network.ap_model import ap_intent_from_db
from network.ap_validator import validate_ap
from network.ap_renderer import render_hostapd, render_udhcpd
@ -39,6 +39,72 @@ def parse_iw_link_channel(out):
# country 별 2.4GHz 기본 채널(MCC opt-in 시)
_DEFAULT_2G = 6
def _parse_first_int(text):
m = re.search(r"-?\d+", text or "")
return int(m.group(0)) if m else None
def _parse_station_dump(out):
clients = []
cur = None
for raw in (out or "").splitlines():
line = raw.strip()
if line.startswith("Station "):
if cur:
clients.append(cur)
parts = line.split()
mac = parts[1].lower() if len(parts) > 1 else ""
cur = {"mac": mac, "ip": None, "signal_dbm": None, "inactive_ms": None, "connected_s": None}
continue
if not cur or ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
if key == "inactive time":
cur["inactive_ms"] = _parse_first_int(value)
elif key == "signal":
cur["signal_dbm"] = _parse_first_int(value)
elif key == "connected time":
cur["connected_s"] = _parse_first_int(value)
if cur:
clients.append(cur)
return [c for c in clients if c.get("mac")]
def _client_stub(mac):
return {"mac": mac, "ip": None, "signal_dbm": None, "inactive_ms": None, "connected_s": None}
def _parse_hostapd_sta_list(out):
macs = []
seen = set()
for raw in (out or "").splitlines():
line = raw.strip().lower()
if not re.fullmatch(r"[0-9a-f]{2}(:[0-9a-f]{2}){5}", line):
continue
if line not in seen:
macs.append(line)
seen.add(line)
return macs
def _without_macs(clients, macs):
blocked = {m.lower() for m in macs if m}
return [c for c in clients if c.get("mac", "").lower() not in blocked]
def _parse_neigh_by_mac(out):
ips = {}
for raw in (out or "").splitlines():
parts = raw.split()
if not parts or "lladdr" not in parts:
continue
idx = parts.index("lladdr")
if idx + 1 < len(parts):
ips[parts[idx + 1].lower()] = parts[0]
return ips
class ApEngine:
def __init__(self, ap_dir, state_path, runner=None,
live_country=None, sta_channel=None, country_pending=None):
@ -61,7 +127,8 @@ class ApEngine:
# ap_channel 0(DEFAULT)=auto 센티넬 → 'or'가 기본/STA 채널을 채움(의도된 동작)
if band == "2g":
return (intent.get("ap_channel") or _DEFAULT_2G, "g")
return (intent.get("ap_channel") or (sta_ch or 36), "a")
sta5 = sta_ch if sta_hw == "a" else None
return (intent.get("ap_channel") or (sta5 or 36), "a")
def _persist(self, state, intent):
try:
@ -139,13 +206,37 @@ class ApEngine:
_rc_h, out_h = self.runner(["systemctl", "is-active", "dpworld-hostapd-ap0.service"], 5)
hostapd_running = (out_h or "").strip() == "active"
clients = 0
client_details = []
ap_mac = ""
rc_m, out_m = self.runner(["cat", "/sys/class/net/ap0/address"], 5)
if rc_m == 0:
ap_mac = (out_m or "").strip().splitlines()[0].lower() if (out_m or "").strip() else ""
rc_s, out_s = self.runner(["iw", "dev", "ap0", "station", "dump"], 5)
station_details = []
if rc_s == 0:
clients = sum(1 for ln in (out_s or "").splitlines() if ln.strip().startswith("Station "))
station_details = _without_macs(_parse_station_dump(out_s), [ap_mac])
rc_l, out_l = self.runner(["hostapd_cli", "-i", "ap0", "list_sta"], 5)
if rc_l == 0:
macs = _without_macs([_client_stub(m) for m in _parse_hostapd_sta_list(out_l)], [ap_mac])
if macs or not (out_l or "").strip():
by_station = {c["mac"]: c for c in station_details}
client_details = [by_station.get(c["mac"], c) for c in macs]
else:
client_details = station_details
else:
client_details = station_details
clients = len(client_details)
if client_details:
rc_n, out_n = self.runner(["ip", "neigh", "show", "dev", "ap0"], 5)
if rc_n == 0:
by_mac = _parse_neigh_by_mac(out_n)
for client in client_details:
client["ip"] = by_mac.get(client["mac"])
return {
"ap_enabled": ap_enabled,
"ap0_up": ap0_up,
"hostapd_running": hostapd_running,
"clients": clients,
"client_details": client_details,
"country_pending": bool(self._country_pending()),
}

20
src/network/ap_routes.py

@ -8,6 +8,18 @@ class RouteError(Exception):
AP_FIELDS = frozenset(DEFAULT_AP_CONFIG.keys())
def _req_bool(body, name):
"""Fix #2: strict bool parser — matches net_routes._req_bool semantics.
None/absent False, real bool that value, anything else RouteError(400).
Prevents JSON string "false" being truthy (silently activating dry-run)."""
v = body.get(name)
if v is None:
return False
if isinstance(v, bool):
return v
raise RouteError(400, f"{name} must be a boolean")
class ApRoutes:
def __init__(self, engine, db):
self.engine = engine; self.db = db
@ -24,6 +36,9 @@ class ApRoutes:
for k in ("ap_ssid", "ap_passphrase", "ap_ip", "dhcp_start", "dhcp_end", "ap_band"):
if k in clean and not isinstance(clean[k], str):
raise RouteError(400, f"{k} must be a string")
# blank passphrase = keep existing (frontend blank=keep contract — mirror server-side)
if clean.get("ap_passphrase") == "":
clean.pop("ap_passphrase")
def _merge(current):
cur = dict(current) if isinstance(current, dict) else {}
@ -37,7 +52,7 @@ class ApRoutes:
if not isinstance(body, dict):
raise RouteError(400, "body must be an object")
cur = self.db.get_config("ap_config") or dict(DEFAULT_AP_CONFIG)
if body.get("dry_run"):
if _req_bool(body, "dry_run"): # Fix #2: strict bool (matches net_routes._req_bool)
# Never leak the WPA2 PSK over the unauthenticated API (response only;
# storage keeps it — frontend treats a blank field as "keep existing").
safe = dict(cur)
@ -48,6 +63,9 @@ class ApRoutes:
def status(self):
st = dict(self.engine.status())
cfg = {**DEFAULT_AP_CONFIG, **(self.db.get_config("ap_config") or {})}
# Fix #3a: expose has_passphrase before stripping the PSK — lets the frontend
# detect "no PSK stored" on a fresh device without leaking the secret.
st["has_passphrase"] = bool(cfg.get("ap_passphrase"))
# Strip the WPA2 PSK before returning (unauthenticated API).
cfg.pop("ap_passphrase", None)
st["config"] = cfg

213
src/network/apply_engine.py

@ -226,8 +226,8 @@ class ApplyEngine:
country_changed = bool(target_cc) and any(d["field"] == "wlan0.country_code" for d in diff)
if prior_pending and not country_now and diff:
# C2: 보류 country 가 있는 동안 추가 적용 동결 — deferral 분기로 새는 silent commit 차단
errs.append("country 변경이 재부팅 대기 중 (§6.2) — 추가 적용은 재부팅 후에, 또는 "
"'country 지금 적용'(country_now) 동의로 보류분과 함께 적용")
errs.append("country change is reboot pending (§6.2) — apply further changes after reboot, or "
"consent to 'apply country now' (country_now) to apply them together with the pending change")
# v1.9: mixed country+other (not country_now) 는 더이상 거절하지 않는다 — split-apply 로 처리 (호출측 split 판정).
return errs, country_changed
@ -250,6 +250,12 @@ class ApplyEngine:
except Exception: # noqa: BLE001 — country gate 실패 → 안전 오류 메시지
gate_errs = ["Cannot evaluate country gate"]
errs.extend(gate_errs)
# 선택된 텔레메트리 업링크가 이 apply 로 게이트웨이를 잃으면 거절(라우트 silent 소멸 방지)
from network import uplink as _uplink_dr
_up_iface_dr = _uplink_dr.selected_iface(self._get_uplink_config())
if _up_iface_dr != "wlan0" and not netmodel.gateway_set(new.get(_up_iface_dr, {})):
errs.append(f"Selected telemetry uplink ({_up_iface_dr}) would lose its gateway "
f"with this change — switch the uplink to Wi-Fi first.")
warnings = list(warns)
ifaces = netmodel.changed_interfaces(diff)
if "eth1" in ifaces: warnings.append("eth1_confirm")
@ -335,6 +341,9 @@ class ApplyEngine:
cur_dev = self.db.get_config("device_config") or {}
cur_it = self._baseline_intent() # ★ 적용본 기준 (§1.2)
new_it = netmodel.intent_from_device({**cur_dev, **new_dev_fields})
from network import uplink as _uplink # device apply 도 uplink 라우트 보존 (Opt 2 (a))
_uplink.attach(new_it, {**cur_dev, **new_dev_fields}, self._get_uplink_config(),
live=_uplink.live_subnets(self.runner))
aid = self._cur["apply_id"]
country_now = self._cur["country_now"]
prior_pending = self._prior_country_pending # I2: _begin 에서 보관
@ -342,7 +351,7 @@ class ApplyEngine:
diff = netmodel.diff_intents(cur_it, new_it)
if not diff:
self._cur["state"] = "NOOP"; self._persist() # country_pending 은 prior 보존 (I2)
self._step("VALIDATING", "ok", "no change (적용본 == DB)", aid)
self._step("VALIDATING", "ok", "no change (applied == DB)", aid)
return {"state": "NOOP", "apply_id": aid}
errs, warns = validator.validate(new_it)
errs = list(errs)
@ -352,6 +361,11 @@ class ApplyEngine:
# C2: 보류 country 동결 게이트 포함 — dry_run 과 공용 헬퍼 (분기 분산 금지).
gate_errs, country_changed = self._country_gate_errors(diff, new_it, country_now, prior_pending)
errs.extend(gate_errs)
# 선택된 텔레메트리 업링크가 이 apply 로 게이트웨이를 잃으면 거절(라우트 silent 소멸 방지)
_up_iface = _uplink.selected_iface(self._get_uplink_config())
if _up_iface != "wlan0" and not netmodel.gateway_set(new_it.get(_up_iface, {})):
errs.append(f"Selected telemetry uplink ({_up_iface}) would lose its gateway "
f"with this change — switch the uplink to Wi-Fi first.")
if errs:
self._cur["state"] = "FAILED_VALIDATION"; self._persist()
self._step("VALIDATING", "fail", "; ".join(errs), aid)
@ -369,7 +383,7 @@ class ApplyEngine:
f"{len(diff)} fields" + (" [force]" if self._cur["force"] else ""), aid) # §5.3 force 명시
if any(d["field"] in ("wlan0.dns1", "wlan0.dns2") for d in diff):
self.journal.event("apply", phase="VALIDATING", action="dns_saved_only", result="warn",
apply_id=aid, detail={"note": "dns1/dns2 는 현 펌웨어 미적용 (§12-3)"})
apply_id=aid, detail={"note": "dns1/dns2 not applied by current firmware (§12-3)"})
# SNAPSHOT
self._cur["state"] = "SNAPSHOT"; self._persist()
snap = snapshot.take(self.net_dir, self.backups_dir, aid, self._db_network_fields())
@ -378,18 +392,20 @@ class ApplyEngine:
# WRITING — DB-first (§4.1)
self._cur["state"] = "WRITING"; self._persist()
self._write_db(new_dev_fields)
if split_country:
# wpa conf 는 적용본(OLD) country 로 — 비country 적용 시 regdomain 라이브 누출 방지.
# country-only + 미지정 → reboot-deferred (§6.2). split 과 동일하게 wpa conf 는 적용본(OLD)
# country 로 렌더해야 함 — 안 그러면 보류 중 watchdog wpa 래더(wpa_cli reconfigure)가
# NEW regdomain 을 라이브 누출 (review B2). 마커/persist 는 NEW(리부팅 시 firmware 적용).
defer_country_only = (country_changed and not country_now
and len(diff) == 1 and diff[0]["field"] == "wlan0.country_code")
if split_country or defer_country_only:
self._write_renders(new_it, wpa_country=cur_it["wlan0"]["country_code"])
if split_country:
self._cur["country_pending"] = True
else:
self._write_renders(new_it)
self._step("WRITING", "ok", "db+json+renders" + (" [split]" if split_country else ""), aid)
# country-only + 미지정 → deferred (§6.2): apply.service 미기동
# C2: deferral 은 diff 가 정확히 country 단독일 때만 — 다른 필드가 이 분기로 새어
# "COMMITTED 인데 미적용" 이 되는 lockout hole 차단 (혼합은 위 게이트에서 이미 거절)
if country_changed and not country_now \
and len(diff) == 1 and diff[0]["field"] == "wlan0.country_code":
if defer_country_only:
self._cur.update({"state": "COMMITTED", "country_pending": True}); self._persist()
self._lkg_bookkeeping(aid)
self._step("COMMITTED", "ok", "country deferred — reboot required", aid)
@ -420,13 +436,13 @@ class ApplyEngine:
self._run(["systemctl", "restart", "wpa_supplicant@wlan0.service"], 30, aid)
target = new_it["wlan0"]["country_code"]
if not self.exists("/sys/module/wlan"):
return self._rollback_from(("APPLYING",), "country_now: /sys/module/wlan 부재 (§6.2)")
return self._rollback_from(("APPLYING",), "country_now: /sys/module/wlan missing (§6.2)")
if not self.exists("/sys/class/net/wlan0"):
return self._rollback_from(("APPLYING",), "country_now: wlan0 netdev 부재 (§6.2)")
return self._rollback_from(("APPLYING",), "country_now: wlan0 netdev missing (§6.2)")
live = self._live_country() # iw reg / sysfs — 라디오 실효 regdomain (v1.9.1: wpa_cli 아님)
if live != target:
return self._rollback_from(("APPLYING",),
f"country_now: live {live!r} != 목표 {target!r} (§6.2)")
f"country_now: live {live!r} != target {target!r} (§6.2)")
# C2: 보류분 동반 적용 확인 — pending 해소 (이후 _persist 들이 False 를 기록)
self._cur["country_pending"] = False
self._step("APPLYING", "ok", "", aid)
@ -439,7 +455,7 @@ class ApplyEngine:
if not self._cur["force"]:
return self._rollback_from(("VERIFYING",), "verify failed") # C1: claim 후 본문
self.journal.event("apply", phase="VERIFYING", action="force_commit", result="warn",
apply_id=aid, detail={"note": "verify fail 을 force 로 무시 (§5.3)"})
apply_id=aid, detail={"note": "verify fail overridden by force (§5.3)"})
# eth1 → CONFIRM_WAIT (§6.1) — 엔진 자체 타이머가 1차 만료 보장
if "eth1" in self._cur["changed_ifaces"]:
self._cur.update({"state": "CONFIRM_WAIT",
@ -510,7 +526,7 @@ class ApplyEngine:
self.journal.event("apply", phase="ROLLING_BACK",
action="rollback_empty_baseline_kept_renders", result="warn",
apply_id=aid,
detail={"note": "빈 baseline 스냅샷 — 적용본 렌더 유지 (eth1 lockout 방지)"})
detail={"note": "empty baseline snapshot — keeping applied renders (eth1 lockout prevention)"})
snapshot.restore_files(snap, self.net_dir)
# 재적용 — rollback re-apply: apply.service 는 best-effort (DEF-2a 동일 이유).
# rollback re-verify 가 실 게이트 — verify fail → FAILED_CRITICAL (올바른 동작).
@ -568,7 +584,7 @@ class ApplyEngine:
self.journal.event("apply", phase="ROLLED_BACK", action="rollback_left_drift",
result="warn", apply_id=aid,
detail={"fields": [d["field"] for d in drift],
"note": "user-saved 변경이 drift 로 남음 — 재시도 가능"})
"note": "user-saved change remains as drift — retryable"})
except Exception: # noqa: BLE001
pass
self._step("ROLLED_BACK", "ok", "", aid)
@ -668,6 +684,171 @@ class ApplyEngine:
return bool(self._cur.get("country_pending"))
return self._read_persisted_pending()
# ── uplink (Opt 2: 전용 경로, device diff 머신 무접촉) ──────────
def _get_uplink_config(self):
try:
uc = self.db.get_config("uplink_config")
except Exception: # noqa: BLE001
uc = None
return uc if isinstance(uc, dict) else {}
def _set_uplink_config(self, uc):
def mut(_cur):
return dict(uc) if isinstance(uc, dict) else {}
self.db.update_config("uplink_config", mut, default={})
def _uplink_intent(self, iface):
from network import uplink as _uplink
dev = self.db.get_config("device_config") or {}
# review U1: iface 설정은 '적용본' baseline(network_config.json) 기준 — Save 가 갱신한
# (미적용) device_config 드리프트를 uplink 전환이 몰래 적용/락아웃하거나 diff baseline 을
# 오염시키는 것 차단. baseline 부재(최초 적용 전)일 때만 DB 로 폴백. telemetry 대상 IP 는
# dev(device_config)에서 읽음(attach) — 라우트는 실제 서버 IP 를 향해야 하므로.
try:
with open(os.path.join(self.net_dir, "network_config.json"), encoding="utf-8") as f:
doc = json.load(f)
# codex C2: 부분/빈 persist 는 유효 baseline 아님 — render_persist_json 은 항상 세 iface
# dict 를 내보내므로, 셋 다 dict 가 아니면 손상/부분으로 보고 device_config 로 폴백
# (빈 baseline 으로 iface 렌더를 비우는 wipe 차단). _baseline_intent(diff용)는 무관.
if not (isinstance(doc, dict)
and all(isinstance(doc.get(k), dict) for k in ("wlan0", "eth0", "eth1"))):
raise ValueError("incomplete network_config.json baseline")
it = renderer.intent_from_persist(doc)
except (OSError, ValueError):
it = netmodel.intent_from_device(dev)
_uplink.attach(it, dev, {"telemetry_iface": iface, "mode": "route"},
live=_uplink.live_subnets(self.runner))
return it
def _begin_uplink(self, iface):
"""BUSY 가드 + _cur 셋업. 반환 (started, aid, affected, prior_uc).
country_pending _read_persisted_pending() 으로 보존(Codex #2 — reboot-deferred country 가
uplink 적용으로 silent clear 되는 차단; apply_uplink _begin() 타므로 여기서 직접 보존)."""
from network import uplink as _uplink
with self._lock:
if self._cur and self._cur["state"] in _ACTIVE + ("CONFIRM_WAIT",):
return False, self._cur["apply_id"], None, None
self._seq += 1
aid = f"up-{time.strftime('%Y%m%d-%H%M%S')}-{int(self.clock()) % 1000}-{self._seq}"
prior_uc = self._get_uplink_config()
prior_pending = self._read_persisted_pending()
old_iface = _uplink.selected_iface(prior_uc)
affected = sorted({old_iface, iface} - {_uplink.DEFAULT_IFACE})
if iface == _uplink.DEFAULT_IFACE:
# Codex: wlan0 선택 = stale 복구 케이스. affected가 비면 _run_uplink가
# 어떤 iface도 reconfigure 안 함 → networkd가 기존 /32 route를 유지.
# 보수적으로 eth0/eth1 둘 다 reconfigure하여 새 (uplink-less) 렌더로 드롭.
affected = sorted({"eth0", "eth1"})
self._steps = []
self._cur = {"apply_id": aid, "state": "SNAPSHOT", "force": False, "country_now": False,
"started_monotonic": self.clock(), "confirm_deadline_monotonic": None,
"changed_ifaces": affected, "wpa_changed": False, "country_changed": False,
"country_pending": prior_pending, "snapshot_dir": None, "boot_id": self._boot_id(),
"uplink": {"iface": iface, "old_iface": old_iface}}
self._persist()
return True, aid, affected, prior_uc
def apply_uplink(self, iface):
"""동기 uplink 적용(단위테스트·내부). route-only → CONFIRM_WAIT 없음.
snapshot/_write_renders/_run/verify_fn 재사용. iface 검증된 (post_uplink validate_selection 선수행)."""
if self.country_pending(): # codex C1: country 재부팅 보류 중 — 수동 uplink 차단
return {"state": "COUNTRY_PENDING"}
started, aid, affected, prior_uc = self._begin_uplink(iface)
if not started:
return {"state": "BUSY", "apply_id": aid}
return self._run_uplink(aid, iface, affected, prior_uc)
def apply_uplink_async(self, iface):
"""Codex #8: STARTED+apply_id 즉시 반환 후 데몬 스레드 — eth1 reconfigure 가 관리 HTTP 끊는 것 방지.
status 폴링은 기존 /api/network/apply/status 재사용. API(post_uplink) 이걸 호출."""
if self.country_pending(): # codex C1: country 재부팅 보류 중 — 수동 uplink 차단
return {"state": "COUNTRY_PENDING"}
started, aid, affected, prior_uc = self._begin_uplink(iface)
if not started:
return {"state": "BUSY", "apply_id": aid}
self._thread = threading.Thread(target=self._run_uplink, args=(aid, iface, affected, prior_uc),
daemon=True, name="net-uplink-apply")
self._thread.start()
return {"state": "STARTED", "apply_id": aid}
def _run_uplink(self, aid, iface, affected, prior_uc):
try:
snap = snapshot.take(self.net_dir, self.backups_dir, aid, self._db_network_fields())
self._cur["snapshot_dir"] = snap; self._persist()
self._step("SNAPSHOT", "ok", snap, aid)
self._cur["state"] = "WRITING"; self._persist()
self._set_uplink_config({"telemetry_iface": iface, "mode": "route"})
new_it = self._uplink_intent(iface)
from network import uplink as _uplink # codex C3: engine-side 검증 — watchdog/내부 호출자도 차단
verrs = _uplink.validate_selection(iface, new_it)
if verrs:
return self._rollback_uplink("uplink validation failed: " + "; ".join(verrs),
prior_uc, affected)
self._write_renders(new_it)
self._step("WRITING", "ok", f"uplink={iface} affected={affected}", aid)
self._cur["state"] = "APPLYING"; self._persist()
self._run(["systemctl", "start", "dpworld-network-apply.service"],
APPLY_SERVICE_TIMEOUT, aid, must_ok=False)
self._run(["networkctl", "reload"], 15, aid)
for ifc in affected:
self._run(["networkctl", "reconfigure", ifc], 15, aid)
self._step("APPLYING", "ok", "", aid)
self._cur["state"] = "VERIFYING"; self._persist()
vres = self.verify_fn(new_it, affected)
self._step("VERIFYING", vres["result"],
json.dumps(vres["checks"], ensure_ascii=False)[:500], aid)
if vres["result"] == "fail":
return self._rollback_uplink("uplink verify failed", prior_uc, affected)
self._cur["state"] = "COMMITTED"; self._persist()
# Codex #3 Option B: uplink 는 LKG 미마킹(device-network 전용 LKG DB↔파일 desync 방지).
# source-of-truth = uplink_config + watchdog 재assert. prune 만(fail-soft).
try:
snapshot.prune(self.backups_dir)
except Exception: # noqa: BLE001
pass
self._step("COMMITTED", "ok", "", aid)
return {"state": "COMMITTED", "apply_id": aid, "uplink": iface}
except Exception as e: # noqa: BLE001
if self._cur.get("snapshot_dir") is None:
with self._lock:
self._cur["state"] = "ABORTED"; self._persist()
return {"state": "ABORTED", "apply_id": aid, "reason": str(e)}
return self._rollback_uplink(f"unexpected: {e}", prior_uc, affected)
finally:
with self._lock:
if self._cur and self._cur["state"] in _ACTIVE:
self._cur["state"] = "FAILED_CRITICAL"; self._persist()
def _rollback_uplink(self, reason, prior_uc, affected):
aid = self._cur["apply_id"]
self._cur["state"] = "ROLLING_BACK"; self._persist()
self._step("ROLLING_BACK", "warn", reason, aid)
try:
snap = self._cur.get("snapshot_dir")
if not snap or not snapshot.verify(snap):
raise RuntimeError("snapshot missing/corrupt")
self._set_uplink_config(prior_uc) # 선택 복원
snapshot.restore_files(snap, self.net_dir) # 렌더(라우트 포함) 복원
self._run(["networkctl", "reload"], 15, aid)
for ifc in affected:
self._run(["networkctl", "reconfigure", ifc], 15, aid)
from network import uplink as _uplink
restored_it = self._uplink_intent(_uplink.selected_iface(prior_uc))
rres = self.verify_fn(restored_it, affected)
if rres["result"] == "fail":
# uplink 롤백은 라우트 제거/복원에 한정 — 파일+reconfigure 성공한 이상
# re-verify 실패는 운영 단절 X. 저널 warn 후 ROLLED_BACK 유지(F1-robust).
self.journal.event("apply", phase="ROLLING_BACK",
action="uplink_rollback_reverify_warn", result="warn",
apply_id=aid, detail={"checks": rres.get("checks")})
self._cur["state"] = "ROLLED_BACK"; self._persist()
self._step("ROLLED_BACK", "ok", "", aid)
return {"state": "ROLLED_BACK", "apply_id": aid, "reason": reason}
except Exception as e: # noqa: BLE001
self._cur["state"] = "FAILED_CRITICAL"; self._persist()
self._step("FAILED_CRITICAL", "fail", str(e), aid)
return {"state": "FAILED_CRITICAL", "apply_id": aid, "reason": f"{reason}; rollback: {e}"}
def recover_on_startup(self, block=False):
"""server 시작 시: ① country_pending 해소 검사 ② 미완료 apply 복구 (§5 크래시 안전).

46
src/network/net_routes.py

@ -149,3 +149,49 @@ class NetworkRoutes:
return out
self.engine.db.update_config("net_config", _mut, default={})
return {"ok": True, "net_config": self._current_config()}
# ── uplink (Telemetry Uplink — Ethernet 전환) ─────────────
# codex C3: net_routes._uplink_intent 제거 — get_uplink/post_uplink 가 engine._uplink_intent
# (baseline 기준)로 통일. 기존 _uplink_intent(device_config 기준)는 호출자 0 → 제거.
def get_uplink(self):
"""Codex: 프론트가 'no_gateway 옵션 비활성' 판정에 쓸 per-iface gateway_present 를 응답에 포함.
ifaces 단순 리스트 대신 options:[{iface,gateway_present,disabled_reason?}]."""
from network import netmodel as _nm, uplink as _uplink
uc = self.engine.db.get_config("uplink_config")
uc = uc if isinstance(uc, dict) else {}
# codex C3: device_config 드리프트 대신 engine baseline intent 단일 출처 — validate/status 일치.
# review U7: uc 는 1회 읽고 selected_iface() 에 넘김(비원자 이중읽기 차단 유지).
intent = self.engine._uplink_intent(_uplink.selected_iface(uc))
options = []
for ifc in _uplink.UPLINK_IFACES:
gw_present = (ifc == _uplink.DEFAULT_IFACE) or _nm.gateway_set(intent.get(ifc) or {})
opt = {"iface": ifc, "gateway_present": gw_present,
"advisories": _uplink.selection_advisories(ifc, intent)}
if not gw_present:
opt["disabled_reason"] = "no_gateway"
options.append(opt)
return {"uplink_config": {"telemetry_iface": _uplink.selected_iface(uc),
"mode": uc.get("mode", "route")},
"options": options,
"status": _uplink.status(intent, self.engine.runner)}
def post_uplink(self, body):
from network import uplink as _uplink
if not isinstance(body, dict):
raise RouteError(400, "request body must be a JSON object")
iface = body.get("telemetry_iface")
if not isinstance(iface, str):
raise RouteError(400, "telemetry_iface must be a string")
# codex C3: device_config 드리프트 대신 engine baseline intent 로 검증 — apply 와 동일 출처.
intent = self.engine._uplink_intent(iface)
errs = _uplink.validate_selection(iface, intent)
if errs:
raise RouteError(400, "; ".join(errs))
res = self.engine.apply_uplink_async(iface) # Codex #8: async — STARTED 즉시 반환
if res.get("state") == "COUNTRY_PENDING": # codex C1: reboot-deferred country 보호
raise RouteError(409, "A Wi-Fi country change is pending a reboot — "
"switch the telemetry uplink after rebooting.")
if res.get("state") == "BUSY":
raise RouteError(409, f"apply in flight: {res.get('apply_id')}")
return {"ok": True, **res}

2
src/network/netmodel.py

@ -128,7 +128,7 @@ def diff_intents(a, b):
new_desc = _profile_descriptor(pb)
# password-only change: ssid+security identical but tuples differ → append visible marker
if old_desc == new_desc:
new_desc += " (자격증명 변경)"
new_desc += " (credentials changed)"
out.append({"field": "wlan0.profiles", "old": old_desc, "new": new_desc})
return out

5
src/network/renderer.py

@ -28,6 +28,11 @@ def render_network_file(intent, iface):
if sip and gateway_set(c) and c["mode"] == "static" \
and not _in_subnet(sip, c["ip"], c["netmask"]):
out += f"\n[Route]\nDestination={sip}/32\nGateway={c['gateway']}\n"
# uplink: 선택 iface 에 클라우드 텔레메트리 /32 host-route 추가 (eth0 lte_server 라우트와 공존)
up = intent.get("_uplink") or {}
if up.get("iface") == iface:
from network import uplink as _uplink
out += _uplink.render_routes(intent, iface)
return out
def render_wpa_conf(intent):

381
src/network/uplink.py

@ -0,0 +1,381 @@
# src/network/uplink.py
"""Telemetry uplink (Ethernet 전환) — dpworldapp 클라우드 텔레메트리(protocol/update/rtcm)를
선택 인터페이스로 보내는 /32 host-route 생성·검증·상태 (spec 2026-06-25).
dpworldapp 소유 서버 IP는 읽기 전용; 선택은 소유 uplink_config ."""
import re
from network.netmodel import _s, _ip_u32, gateway_set
from network.renderer import _in_subnet
CLOUD_TELEMETRY_KEYS = (
("protocol_server_ip", "protocol_server_port"),
("update_server_ip", "update_server_port"),
("rtcm_server_ip", "rtcm_server_port"),
)
# UI display labels for the cloud telemetry servers — TIOT=protocol stream,
# Update=firmware update, DGPS=RTCM corrections. Display-only; never affects routing/targets.
CLOUD_TELEMETRY_LABELS = {
"protocol_server_ip": "TIOT",
"update_server_ip": "Update",
"rtcm_server_ip": "DGPS",
}
UPLINK_IFACES = ("wlan0", "eth0", "eth1")
DEFAULT_IFACE = "wlan0" # 라우트 없음 = 현 default route 거동
def _plen_to_mask(plen):
"""prefix-length (int 0-32) → dotted-quad netmask 문자열. stdlib만 사용(ipaddress 불필요)."""
n = (0xffffffff << (32 - int(plen))) & 0xffffffff
return "%d.%d.%d.%d" % (n >> 24, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff)
def live_subnets(runner, ifaces=UPLINK_IFACES):
"""{ifc: (ip, netmask)} — 현재 IPv4 주소가 살아 있는 인터페이스 맵 (DHCP 포함).
'ip -o -4 addr show dev <ifc>' 파싱; inet <a.b.c.d>/<plen> 토큰 추출·변환.
fail-safe: ifc별 try/except rc0·no-match·예외 해당 ifc 생략, raise 없음.
on-link 탐지 보강에만 사용(렌더·static IP 경로 미영향, U3)."""
out = {}
for ifc in ifaces:
try:
rc, text = runner(["ip", "-o", "-4", "addr", "show", "dev", ifc], 5)
if rc != 0:
continue
m = re.search(r'\binet\s+(\d+\.\d+\.\d+\.\d+)/(\d+)\b', text or "")
if not m:
continue
out[ifc] = (m.group(1), _plen_to_mask(int(m.group(2))))
except Exception: # noqa: BLE001 — fail-safe: ifc 생략, 절대 raise 안 함
continue
return out
def _telemetry_servers(device_config):
"""[(ip, [ip_key, ...]), ...] — deduped cloud telemetry IPs in CLOUD_TELEMETRY_KEYS first-seen
order, each paired with EVERY key that targets it. Empty/invalid IPs (_ip_u32 0/None) excluded.
SINGLE source for telemetry_targets (the IP list) and telemetry_target_labels (joined labels)
so their IP sets can never drift (fix-the-twin: one filter, not two copies)."""
dc = device_config if isinstance(device_config, dict) else {}
order, by_ip = [], {}
for ip_key, _port_key in CLOUD_TELEMETRY_KEYS:
ip = _s(dc.get(ip_key))
if ip and _ip_u32(ip) not in (0, None):
if ip not in by_ip:
by_ip[ip] = []
order.append(ip)
by_ip[ip].append(ip_key)
return [(ip, by_ip[ip]) for ip in order]
def telemetry_targets(device_config):
"""클라우드 텔레메트리 서버 IP(읽기 전용, 빈/비파싱 제외·중복 제거·순서 보존).
로컬 opc_ua/modbus·lte_server 미포함(CLOUD_TELEMETRY_KEYS 한정). _telemetry_servers 단일 출처."""
return [ip for ip, _keys in _telemetry_servers(device_config)]
def telemetry_target_labels(device_config):
"""{ip: label}. 같은 IP에 여러 서버(예: protocol=TIOT, rtcm=DGPS — 같은 호스트·다른 포트)면 라벨 join
('TIOT / DGPS') /32 host-route per-IP(포트 무관) 라우트가 커버 표기. 표시 전용.
_telemetry_servers 단일 출처라 telemetry_targets 집합이 구조적으로 동일(drift 불가)."""
out = {}
for ip, keys in _telemetry_servers(device_config):
labels = []
for k in keys:
lbl = CLOUD_TELEMETRY_LABELS.get(k, k)
if lbl not in labels:
labels.append(lbl)
out[ip] = " / ".join(labels)
return out
def selected_iface(uplink_config):
"""uplink_config.telemetry_iface ∈ UPLINK_IFACES. 그 외/부재 → DEFAULT_IFACE."""
uc = uplink_config if isinstance(uplink_config, dict) else {}
ifc = _s(uc.get("telemetry_iface"))
return ifc if ifc in UPLINK_IFACES else DEFAULT_IFACE
def attach(intent, device_config, uplink_config, live=None):
"""intent['_uplink'] = {'iface','targets','labels','live_subnets'} 주입(in-memory).
persist(network_config.json) 미기록(§5.2). 렌더러/verifier/watchdog 공용 단일 출처.
device-network apply _write_renders 호출(라우트 wipe 방지).
live: live_subnets() 결과({ifc:(ip,mask)}) None/미지정 {} (기존 호출자 무영향, U3)."""
intent["_uplink"] = {"iface": selected_iface(uplink_config),
"targets": telemetry_targets(device_config),
"labels": telemetry_target_labels(device_config),
"live_subnets": live or {}}
def validate_selection(iface, intent):
"""선택 검증 — errors 목록. iface enum + (비-wlan0면) static-gateway 존재.
intent device 필드가 채워진 상태(net_dir baseline 또는 device_config 유도)."""
if iface not in UPLINK_IFACES:
return [f"telemetry_iface must be one of {UPLINK_IFACES}"]
if iface != DEFAULT_IFACE:
c = intent.get(iface) or {}
if not gateway_set(c):
return ["no_gateway"]
return []
def _on_link_ifaces(ip, intent):
"""All UPLINK_IFACES whose subnet contains `ip` (UPLINK_IFACES order).
Primary source: CONFIGURED ip+netmask from the intent (static ifaces).
Live fallback (U3): when configured ip/netmask is empty (DHCP iface), falls back to
intent['_uplink']['live_subnets'][ifc] supplied by callers with a runner (attach live= kwarg).
Absent live_subnets today's behavior (DHCP iface never matches). Fail-safe: a failed
live_subnets read degrades to {} and never installs a wrong /32. An iface with empty
ip/netmask (from both sources) never matches (empty netmask would read as /0 = match-all).
length>1 result means overlapping subnets (misconfiguration selection_advisories surfaces it)."""
live = (intent.get("_uplink") or {}).get("live_subnets") or {}
out = []
for ifc in UPLINK_IFACES:
c = intent.get(ifc) or {}
cip, mask = _s(c.get("ip")), _s(c.get("netmask"))
if not (cip and mask):
# DHCP iface(또는 미설정): live 주소로 폴백 — 부재 시 ""/"" → 여전히 미매칭(fail-safe)
lr = live.get(ifc)
if lr:
cip, mask = lr
if cip and mask and _in_subnet(ip, cip, mask):
out.append(ifc)
return out
def _on_link_iface(ip, intent):
"""First interface `ip` is on-link via (configured), or None. Deterministic by UPLINK_IFACES
order. Configured, not live route proof (see _on_link_ifaces)."""
ifaces = _on_link_ifaces(ip, intent)
return ifaces[0] if ifaces else None
def selection_advisories(iface, intent):
"""Soft, English pre-apply advisories for selecting `iface` (the candidate). PURE — no runner,
no I/O. Reads the iface-independent targets/labels from intent['_uplink'] (attach()-populated;
same source as plan_routes/status) and per-iface subnets from intent[<ifc>]. Returns [] when
'_uplink' is absent. Rules (spec 2026-06-26 §5.2), in emitted order: 0 overlap-subnet,
1 local-via-other-iface, 2 all-local (non-wlan0), 3 wlan0 revert."""
up = intent.get("_uplink")
if not isinstance(up, dict):
return [] # spec §5.2: absent _uplink → [] for EVERY iface (incl. wlan0 — no targets to revert)
targets = up.get("targets") or []
labels = up.get("labels") or {}
out = []
# rule 0: overlapping-subnet misconfiguration (a target on-link via >1 iface)
for ip in targets:
ifs = _on_link_ifaces(ip, intent)
if len(ifs) > 1:
lbl = labels.get(ip) or ip
out.append("%s %s matches more than one interface subnet (%s) — check the interface "
"configuration; the displayed interface is the first match."
% (lbl, ip, ", ".join(ifs)))
# rule 1: target on-link via a DIFFERENT interface than the candidate
for ip in targets:
y = _on_link_iface(ip, intent)
if y is not None and y != iface:
lbl = labels.get(ip) or ip
out.append("%s %s stays on %s (local LAN) — selecting %s won't move it."
% (lbl, ip, y, iface))
# rules 2/3 (mutually exclusive on iface)
if iface != DEFAULT_IFACE:
steerable = [ip for ip in targets if _on_link_iface(ip, intent) is None]
if targets and not steerable:
out.append("All telemetry servers are on local LANs; selecting %s installs no "
"host-routes (this may be correct)." % iface)
else:
out.append("Reverts to the system default route and removes the host-routes.")
return out
def _on_link_any(ip, intent):
"""True if `ip` is on-link via ANY configured interface — directly reachable without a gateway.
Such a target MUST NOT be host-routed via a foreign gateway: doing so black-holes a
locally-reachable host (e.g. selecting eth0 for a 192.168.55.x server on-link via eth1) and
breaks asymmetric routing to/from it (management lockout, 2026-06-26)."""
return bool(_on_link_ifaces(ip, intent))
def plan_routes(intent):
"""(routes, skips). intent['_uplink']={iface,targets} 기준.
iface==DEFAULT_IFACE ([], []). iface 게이트웨이 없음 ([], ['no_gateway']).
타겟이 어느 인터페이스든 on-link(서브넷 ) 타겟 skip('local:<ip>') 선택 iface 게이트웨이로
/32 깔면 on-link 호스트 블랙홀( iface 로컬 서버 + 관리 락아웃, 2026-06-26). 타겟별 /32 route dict.
static-gateway 한정 Gateway=_dhcp4 미사용(systemd 버전 의존 회피)."""
up = intent.get("_uplink") or {}
iface = up.get("iface") or DEFAULT_IFACE
targets = up.get("targets") or []
if iface == DEFAULT_IFACE:
return [], []
c = intent.get(iface) or {}
if not gateway_set(c):
return [], ["no_gateway"]
gw = _s(c.get("gateway"))
routes, skips = [], []
for ip in targets:
if _on_link_any(ip, intent): # on-link via ANY iface → no /32 (would black-hole; see _on_link_any)
skips.append(f"local:{ip}")
continue
routes.append({"dest": ip, "gateway": gw})
return routes, skips
def render_routes(intent, iface):
"""선택 iface .network 에 붙일 [Route] 텍스트. render_network_file 이 iface==_uplink.iface 일 때만 호출."""
routes, _skips = plan_routes(intent)
out = ""
for r in routes:
out += f"\n[Route]\nDestination={r['dest']}/32\nGateway={r['gateway']}\n"
return out
def _routes_show(runner):
rc, out = runner(["ip", "route", "show"], 5)
return out if rc == 0 else ""
def _installed_route(routes, ip):
"""ip route show 에서 host <ip> 라인 파싱 → {'dev','gateway'} 또는 None (Codex #6: dev+gateway).
/32 host suffix 없이 '<ip> via <gw> dev <ifc>' 표기."""
for ln in routes.splitlines():
toks = ln.split()
if not toks or toks[0] != ip:
continue
dev = toks[toks.index("dev") + 1] if "dev" in toks else ""
gw = toks[toks.index("via") + 1] if "via" in toks else ""
return {"dev": dev, "gateway": gw}
return None
def stale_routes(targets, routes):
"""클라우드 타겟(목록)이 eth0/eth1 에 /32 로 잔존하면 [{ip,dev}] 반환 (Codex #5 — wlan0 선택 시 비어야 ok)."""
out = []
for ip in targets:
r = _installed_route(routes, ip)
if r and r["dev"] in ("eth0", "eth1"):
out.append({"ip": ip, "dev": r["dev"]})
return out
def _addr_to_iface(intent):
"""intent per-iface ip → iface 이름. egress local IP 를 iface 로 매핑(정직 표기)."""
m = {}
for ifc in UPLINK_IFACES:
ip = _s((intent.get(ifc) or {}).get("ip"))
if ip:
m[ip] = ifc
return m
def observe_egress(targets, runner):
"""ss -tn(없으면 /proc/net/tcp 폴백)로 각 target 으로의 ESTAB 소켓 local IP 관측. {target_ip: local_ip|None}.
dpworldapp 실제 송신 경로의 ground truth '적용됨' 'working' 으로 위장 금지."""
res = {ip: None for ip in targets}
rc, out = runner(["ss", "-tn"], 5)
if rc != 0:
return _egress_from_proc(targets, runner, res)
for ln in out.splitlines():
toks = ln.split()
if len(toks) < 5 or toks[0] != "ESTAB":
continue
peer, local = toks[-1], toks[-2]
if ":" in peer and ":" in local:
pip = peer.rsplit(":", 1)[0]
if pip in res:
res[pip] = local.rsplit(":", 1)[0]
return res
def _egress_from_proc(targets, runner, res):
"""ss 부재(busybox) 폴백 — /proc/net/tcp hex 파싱. 실패 시 모두 None 유지(거짓 green 금지)."""
rc, out = runner(["cat", "/proc/net/tcp"], 5)
if rc != 0:
return res
want = {_ip_to_hex_le(ip): ip for ip in targets}
for ln in out.splitlines()[1:]:
f = ln.split()
if len(f) < 4 or f[3] != "01": # 01 = ESTABLISHED
continue
rem_hex = f[2].split(":")[0]
if rem_hex in want:
loc_hex = f[1].split(":")[0]
res[want[rem_hex]] = _hex_le_to_ip(loc_hex)
return res
def _ip_to_hex_le(ip):
parts = ip.split(".")
if len(parts) != 4:
return ""
return "".join(f"{int(p):02X}" for p in reversed(parts))
def _hex_le_to_ip(h):
try:
b = [int(h[i:i + 2], 16) for i in (6, 4, 2, 0)]
return ".".join(str(x) for x in b)
except (ValueError, IndexError):
return ""
def reachability(targets, iface, gateway, runner):
"""비침습 도달성 — ip route show 로 <ip> via <gateway> dev <iface> 설치 확인(dev+gateway 둘 다 — Codex #6)."""
routes = _routes_show(runner)
out = []
for ip in targets:
r = _installed_route(routes, ip)
out.append({"ip": ip,
"route_installed": bool(r) and r["dev"] == iface and r["gateway"] == gateway})
return out
def status(intent, runner):
"""3단 정직 상태. {iface, per_target:[{ip,label,on_link_iface,route_installed,local,egress_iface}], rollup}.
label = 서버 표시명(TIOT/Update/DGPS, 표시 전용). rollup {unset, pending, egress, unreachable, stale}
(언어중립 코드 프론트 영문 매핑). 'working' 절대 주장 X.
라우트 판정은 dev+gateway (Codex #6). wlan0 선택 시 eth0/eth1 stale /32 잔존 검사(Codex #5).
선택 iface 서브넷 타겟(local) /32 불요(on-link 직접 도달) route_installed 거짓표시 금지(Codex)."""
up = intent.get("_uplink") or {}
iface = up.get("iface") or DEFAULT_IFACE
targets = up.get("targets") or []
labels = up.get("labels") or {}
routes = _routes_show(runner)
egress = observe_egress(targets, runner)
a2i = _addr_to_iface(intent)
gw = _s((intent.get(iface) or {}).get("gateway"))
# local targets (in the selected iface subnet) need NO /32 route — on-link direct. Use plan_routes'
# classification so status agrees with what apply installs (Codex: don't show "unreachable/missing"
# for a target deliberately skipped as on-link-local).
local_ips = {s.split(":", 1)[1] for s in plan_routes(intent)[1] if s.startswith("local:")}
per = []
for ip in targets:
if ip in local_ips:
installed, is_local = True, True # on-link, no host-route needed (direct)
elif iface == DEFAULT_IFACE:
installed, is_local = False, False
else:
r = _installed_route(routes, ip)
installed, is_local = bool(r) and r["dev"] == iface and r["gateway"] == gw, False
egr = egress.get(ip)
per.append({"ip": ip, "label": labels.get(ip), "on_link_iface": _on_link_iface(ip, intent),
"route_installed": installed, "local": is_local,
"egress_iface": a2i.get(egr) if egr else None})
stale = stale_routes(targets, routes) if iface == DEFAULT_IFACE else []
return {"iface": iface, "per_target": per, "rollup": _rollup(iface, per, stale)}
def _rollup(iface, per, stale):
"""언어중립 status 코드(프론트가 영문 표시로 매핑) — 영문 UI에 한국어 누출 방지.
honesty: 'working' 추론 green 없음. egress=실제 송신 관측만 confirmed."""
if iface == DEFAULT_IFACE:
return "stale" if stale else "unset"
if not per:
return "unset"
if any(p["egress_iface"] == iface for p in per):
# Genuine unreachable: non-local target with no route AND no observed egress — don't mask
genuinely_unreachable = any(
not p["local"] and not p["route_installed"] and p["egress_iface"] is None
for p in per
)
if not genuinely_unreachable:
return "egress"
if all(p["route_installed"] for p in per):
return "pending"
return "unreachable"

32
src/network/validator.py

@ -43,61 +43,61 @@ def validate(intent):
w = intent["wlan0"]
profs = w.get("profiles", [])
if len(profs) > MAX_PROFILES:
errs.append(f"wlan0.profiles: 최대 {MAX_PROFILES}개 (현재 {len(profs)})")
errs.append(f"wlan0.profiles: max {MAX_PROFILES} (currently {len(profs)})")
# gap 검출 (§5.2): 첫 빈 SSID 이후의 비어있지 않은 프로파일은 렌더에서 침묵 탈락 → 거부.
# trailing 빈 슬롯(UI 빈 행)은 허용.
first_empty = next((i for i, p in enumerate(profs) if not p["ssid"]), None)
if first_empty is not None:
for j in range(first_empty + 1, len(profs)):
if profs[j]["ssid"]:
errs.append(f"wlan0.profiles[{j}]: gap 금지 — 빈 슬롯[{first_empty}] 뒤 프로파일은 적용되지 않음 (압축 저장 필요)")
errs.append(f"wlan0.profiles[{j}]: no gap allowed — profiles after empty slot[{first_empty}] are not applied (must be stored compacted)")
for i, p in enumerate(profs):
tag = f"wlan0.profiles[{i}]"
if not p["ssid"]:
continue # 빈 슬롯 자체는 gap 검사에서 처리
ssid_bytes = len(p["ssid"].encode("utf-8"))
if ssid_bytes > MAX_SSID:
errs.append(f"{tag}: SSID {ssid_bytes}바이트 > {MAX_SSID} (펌웨어 절단)")
errs.append(f"{tag}: SSID {ssid_bytes} bytes > {MAX_SSID} (firmware truncates)")
# C2: SSID 에 ", \, 또는 제어문자 금지 (wpa 렌더 escaping 없음 + injection 방지)
if _has_forbidden_char(p["ssid"]):
errs.append(f"{tag}: SSID \", \\\\, 또는 제어문자 금지 (wpa 렌더 escaping 없음)")
errs.append(f"{tag}: SSID must not contain \", \\\\, or control characters (wpa render has no escaping)")
if p["security"] not in ALLOWED_SECURITY:
errs.append(f"{tag}: security={p['security']!r} 불가 — {ALLOWED_SECURITY} 만 (그 외는 펌웨어가 프로파일 삭제)")
errs.append(f"{tag}: security={p['security']!r} not allowed — only {ALLOWED_SECURITY} (anything else makes the firmware delete the profile)")
elif p["security"] == "none":
if p["password"]:
errs.append(f"{tag}: security=none 이면 password 는 빈 값 강제")
errs.append(f"{tag}: security=none requires password to be empty")
else:
pw_bytes = len(p["password"].encode("utf-8"))
if not (PSK_MIN <= pw_bytes <= PSK_MAX):
errs.append(f"{tag}: password {pw_bytes}바이트{PSK_MIN}-{PSK_MAX}바이트 필요")
errs.append(f"{tag}: password {pw_bytes} bytes{PSK_MIN}-{PSK_MAX} bytes required")
# C2: password 에 ", \, 또는 제어문자 금지
if _has_forbidden_char(p["password"]):
errs.append(f"{tag}: password \", \\\\, 또는 제어문자 금지 (wpa 렌더 escaping 없음)")
errs.append(f"{tag}: password must not contain \", \\\\, or control characters (wpa render has no escaping)")
cc = w.get("country_code", "")
if cc and not (len(cc) == 2 and cc.isalpha() and cc.isupper()):
errs.append(f"wlan0.country_code={cc!r}: 2자리 대문자 알파벳")
errs.append(f"wlan0.country_code={cc!r}: must be 2 uppercase letters")
for iface in ("wlan0", "eth0", "eth1"):
c = intent[iface]
if c["mode"] != "static":
continue
if not _ip_ok(c["ip"]):
errs.append(f"{iface}: static 인데 IP 없음/형식 오류 ({c['ip']!r})")
errs.append(f"{iface}: static mode but IP missing/malformed ({c['ip']!r})")
continue
# C1: netmask must be contiguous (also subsumes the format check — a
# contiguous netmask is necessarily a valid IPv4 address; v1.11.10 Fix 38
# removed the unreachable _ip_ok(netmask) follow-up block).
if not _netmask_contiguous(c["netmask"]):
errs.append(f"{iface}: netmask 비연속/형식 오류 ({c['netmask']!r})")
errs.append(f"{iface}: netmask non-contiguous/malformed ({c['netmask']!r})")
continue
gw = c.get("gateway", "")
if gw: # 빈 gateway 는 허용 — [Route] 생략 (§5.2/§12-2)
if not _ip_ok(gw):
errs.append(f"{iface}: gateway 형식 오류 ({gw!r})")
errs.append(f"{iface}: gateway malformed ({gw!r})")
else:
try:
net = ipaddress.IPv4Network(f"{c['ip']}/{c['netmask']}", strict=False)
if ipaddress.IPv4Address(gw) not in net:
warns.append(f"{iface}: gateway {gw} subnet {net} 밖 — 확인 필요")
warns.append(f"{iface}: gateway {gw} is outside subnet {net} — please verify")
except ValueError:
pass
# I3: port rules — eth0.server_port, eth1.opc_ua_server_port, eth1.modbus_server_port
@ -109,7 +109,7 @@ def validate(intent):
for iface, field in port_checks:
val = intent[iface].get(field, "")
if not _port_ok(val):
errs.append(f"{iface}.{field}: 포트 범위 오류 ({val!r}) — 빈 값 또는 1-65535 정수")
errs.append(f"{iface}.{field}: port out of range ({val!r}) — empty or integer 1-65535")
# v1.11.8 D: server_ip / dns IPv4 format reject (이전엔 비교만 하고 형식 미검증).
# 빈 값 = clear(허용). netmodel._ip_u32 공유 검증기 사용 — 0=빈값, None=형식오류
# (device_config 경로 _is_valid_ip 와 동일한 leading-zero strict 규칙).
@ -125,7 +125,7 @@ def validate(intent):
if val in ("", None):
continue # 빈 값 = clear (허용)
if netmodel._ip_u32(val) is None:
errs.append(f"{iface}.{field}: IP 형식 오류 ({val!r})")
errs.append(f"{iface}.{field}: IP malformed ({val!r})")
# C1: subnet-overlap loop — wrap each pair in try/except ValueError to never throw
statics = [(i, intent[i]) for i in ("wlan0", "eth0", "eth1") if intent[i]["mode"] == "static"
and _ip_ok(intent[i]["ip"]) and _netmask_contiguous(intent[i].get("netmask", ""))]
@ -135,7 +135,7 @@ def validate(intent):
na = ipaddress.IPv4Network(f"{statics[a][1]['ip']}/{statics[a][1]['netmask']}", strict=False)
nb = ipaddress.IPv4Network(f"{statics[b][1]['ip']}/{statics[b][1]['netmask']}", strict=False)
if na.overlaps(nb):
warns.append(f"{statics[a][0]}/{statics[b][0]}: 서브넷 중복 ({na}{nb})")
warns.append(f"{statics[a][0]}/{statics[b][0]}: subnet overlap ({na}{nb})")
except ValueError:
continue
return errs, warns

46
src/network/verifier.py

@ -38,17 +38,17 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc
routes_rc, routes = runner(["ip", "route", "show"], 5)
if not ip_ok:
# I8: ip 실패 = 라이브 상태 미상 → 명시적 fail + per-iface 라이브 검증 생략
add("verifier.ip", "fail", "ip -br addr show 실패 — 라이브 상태 미상 (per-iface 검증 생략)")
add("verifier.ip", "fail", "ip -br addr show failed — live state unknown (per-iface checks skipped)")
return {"result": worst, "checks": checks}
if routes_rc != 0:
add("verifier.ip_route", "fail", "ip route show 실패 — 라우트 검증 불가")
add("verifier.ip_route", "fail", "ip route show failed — cannot verify routes")
routes = ""
for iface in changed_ifaces:
if iface == "country": # reboot-deferred 변경엔 라이브 검증 없음
continue
c = intent.get(iface, {})
if not exists(f"/sys/class/net/{iface}"):
add(f"{iface}.exists", "fail", "netdev 부재"); continue
add(f"{iface}.exists", "fail", "netdev missing"); continue
add(f"{iface}.exists", "ok")
# DEF-1: wlan0 with effective profiles — wpa_state wait loop is the gate regardless of
# momentary carrier state (wrong password makes wpa drop → carrier DOWN before wpa settles).
@ -64,7 +64,7 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc
# carrier check is deferred: emitted ok/warn AFTER wpa result is known.
pass # fall through (do NOT continue, do NOT add carrier check yet)
else:
add(f"{iface}.carrier", "warn", "carrier 없음 — config staged (§5.3)")
add(f"{iface}.carrier", "warn", "no carrier — config staged (§5.3)")
continue # 라이브 검증 생략
else:
add(f"{iface}.carrier", "ok", st)
@ -94,13 +94,13 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc
add(f"{iface}.address", "ok", c["ip"])
else:
add(f"{iface}.address", "fail",
f"기대 {c['ip']} / 실제 {live.get(iface, {}).get('addrs')} (settle {settle_s}s)")
f"expected {c['ip']} / actual {live.get(iface, {}).get('addrs')} (settle {settle_s}s)")
if gw:
if route_ok:
add(f"{iface}.route", "ok", gw)
else:
add(f"{iface}.route", "fail",
f"default via {gw} dev {iface} 부재 (settle {settle_s}s)")
f"default via {gw} dev {iface} missing (settle {settle_s}s)")
if wlan_gated:
deadline = clock() + wpa_wait_s
state = ""
@ -127,7 +127,7 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc
add(f"{iface}.address", "ok", c["ip"])
else:
add(f"{iface}.address", "fail",
f"기대 {c['ip']} / 실제 {live.get(iface, {}).get('addrs')} (post-wpa)")
f"expected {c['ip']} / actual {live.get(iface, {}).get('addrs')} (post-wpa)")
# Fix #14: when carrier was initially down, the route was never
# verified (the carrier-up settle branch above was skipped).
# Re-fetch routes and check the default route (mirror ~98-103).
@ -140,11 +140,11 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc
add(f"{iface}.route", "ok", gw)
else:
add(f"{iface}.route", "fail",
f"default via {gw} dev {iface} 부재 (post-wpa)")
f"default via {gw} dev {iface} missing (post-wpa)")
else:
# deferred carrier check: wpa failed → emit warn for the carrier state we saw
if not carrier_up:
add(f"{iface}.carrier", "warn", "association 진행 중 — wpa_state 대기")
add(f"{iface}.carrier", "warn", "association in progress — waiting for wpa_state")
add("wlan0.wpa", "fail", f"wpa_state={state or 'unknown'} (wait {wpa_wait_s}s)")
gw = c.get("gateway", "")
# minor: ping skip 은 per-iface — 다른 iface 의 fail 이 이 iface 의 ping 을 막지 않는다
@ -155,7 +155,27 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc
rc, _o = runner(["ping", "-c", "1", "-W", "2", gw], 5)
if rc == 0:
ok = True; break
add(f"{iface}.gw_ping", "ok" if ok else "warn", gw if ok else f"{gw} 무응답 (WARN — AP측 원인 가능)")
add(f"{iface}.gw_ping", "ok" if ok else "warn", gw if ok else f"{gw} no response (WARN — may be an AP-side cause)")
# uplink: 선택 iface(비-wlan0)면 기대 /32 라우트 설치를 검증 (changed_ifaces 포함 여부 무관)
up = intent.get("_uplink") or {}
up_iface = up.get("iface")
if up_iface:
from network import uplink as _uplink
rc_u, routes_u = runner(["ip", "route", "show"], 5)
rtxt = routes_u if rc_u == 0 else routes
if up_iface != "wlan0":
for r in _uplink.plan_routes(intent)[0]: # 기대 라우트(서브넷-로컬 제외)
got = _uplink._installed_route(rtxt, r["dest"])
if got and got["dev"] == up_iface and got["gateway"] == r["gateway"]:
add(f"uplink.{r['dest']}", "ok", f"{up_iface} via {r['gateway']}")
else: # dev+gateway 둘 다 일치해야 ok (Codex #6)
add(f"uplink.{r['dest']}", "fail",
f"route {r['dest']} via {r['gateway']} dev {up_iface} not installed (actual={got})")
else:
# wlan0 복귀: 클라우드 타겟이 eth0/eth1 에 stale /32 로 잔존하면 fail (Codex #5)
for s in _uplink.stale_routes(up.get("targets") or [], rtxt):
add(f"uplink.stale.{s['ip']}", "fail",
f"stale route {s['ip']} dev {s['dev']} — wlan0 selected but Ethernet route still present")
# v1.9: split-apply 시 country 는 deferred — 라이브 regdomain 이 절대 NEW 로 새지 않았음을 단언.
# ground truth = iw reg get (wpa_cli get country 는 렌더값일 뿐, firmware 주석대로 거짓양성).
# positive 누출(live==NEW≠OLD)만 fail→롤백; 판독불가/모호는 warn(정상 apply 를 롤백시키지 않음).
@ -169,9 +189,9 @@ def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, cloc
if s.startswith("country ") and len(s) >= 10 and s[8:10].isalpha():
live_cc = s[8:10].upper(); break
if rc != 0 or not live_cc:
add("country.deferred", "warn", "regdomain 판독 불가 — deferred 미확인 (non-fatal)")
add("country.deferred", "warn", "regdomain unreadable — deferred unconfirmed (non-fatal)")
elif live_cc == new_cc and new_cc != old_cc:
add("country.deferred", "fail", f"deferred country 가 라이브로 누출: {live_cc} (기대 {old_cc})")
add("country.deferred", "fail", f"deferred country leaked live: {live_cc} (expected {old_cc})")
else:
add("country.deferred", "ok", f"regdomain {live_cc} 유지 (NEW {new_cc} 는 reboot 후)")
add("country.deferred", "ok", f"regdomain {live_cc} retained (NEW {new_cc} applies after reboot)")
return {"result": worst, "checks": checks}

56
src/network/watchdog.py

@ -105,13 +105,35 @@ class NetworkWatchdog:
detail={"note": "wpa_cli 무응답 — UNKNOWN 처리, 복구 안 함 (C3)"})
def _collect_checks(self):
"""name → bool(healthy). 이름 = wlan_module/wpa_state/addr_route/eth0/eth1.
"""name → bool(healthy). 이름 = wlan_module/wpa_state/addr_route/eth0/eth1/uplink_routes.
테스트에서 통째 주입."""
out = {}
# uplink_routes: 선택 iface 감시 — network_config.json 비의존(Codex #9).
from network import netmodel, uplink as _uplink
try:
uc = self.db.get_config("uplink_config")
dev = self.db.get_config("device_config") or {}
except Exception: # noqa: BLE001
uc, dev = None, {}
up_iface = _uplink.selected_iface(uc if isinstance(uc, dict) else {})
rc_u, routes_u = self.runner(["ip", "route", "show"], 5)
if rc_u == 0: # rc!=0 = UNKNOWN → 키 생략(거짓 unhealthy 금지, C3 패턴)
if up_iface != "wlan0":
it2 = netmodel.intent_from_device(dev)
_uplink.attach(it2, dev, {"telemetry_iface": up_iface},
live=_uplink.live_subnets(self.runner)) # codex C4: U3 4번째 호출 site — DHCP on-link skip
plan, _sk = _uplink.plan_routes(it2)
if plan:
out["uplink_routes"] = all(
(got := _uplink._installed_route(routes_u, r["dest"]))
and got["dev"] == up_iface and got["gateway"] == r["gateway"]
for r in plan)
else: # wlan0 선택: eth0/eth1 stale /32 가 없어야 healthy (Codex #5)
out["uplink_routes"] = not _uplink.stale_routes(
_uplink.telemetry_targets(dev), routes_u)
it = self._intent()
if it is None:
return {}
out = {}
from network import netmodel
return out # ★ {} 아님 — uplink 체크 보존(early-return 앞에서 채움)
wifi_configured = bool(netmodel.effective_profiles(it))
if wifi_configured:
out["wlan_module"] = os.path.isdir("/sys/module/wlan") and \
@ -192,6 +214,32 @@ class NetworkWatchdog:
return rc == 0
def _do_recover(self, name, ladder_idx):
if name == "uplink_routes":
from network import uplink as _uplink
# review U4: country 가 reboot-deferred 보류 중이면 uplink self-heal 건너뜀 — apply_uplink
# 가 wpa conf 를 NEW country 로 재렌더 + apply.service/wpa reconfigure 로 보류된 regdomain
# 을 라이브 누출하는 것 차단(정상 래더의 country_pending 억제와 대칭).
if self.engine.country_pending():
self.journal.event("watchdog", phase="recover", action="skip_apply_uplink",
result="warn", detail={"check": name, "reason": "country_pending (§8)"})
return
uc = self.db.get_config("uplink_config")
up_iface = _uplink.selected_iface(uc if isinstance(uc, dict) else {})
try:
res = self.engine.apply_uplink(up_iface)
state = (res or {}).get("state", "")
if state == "COMMITTED":
result = "ok"
elif state in ("ROLLED_BACK", "BUSY"):
result = "warn"
else: # FAILED_CRITICAL, ABORTED, FAILED_VALIDATION, UNKNOWN
result = "fail"
self.journal.event("watchdog", phase="recover", action="apply_uplink",
result=result, detail={"iface": up_iface, "state": state})
except Exception as e: # noqa: BLE001 — 복구 실패가 watchdog thread 죽이면 안 됨
self.journal.event("watchdog", phase="recover", action="apply_uplink",
result="fail", detail={"error": str(e)})
return
ladder = _ladder_for(name)
step = ladder[min(ladder_idx, len(ladder) - 1)]
for argv in step:

15
src/server.py

@ -80,7 +80,10 @@ from firmware.fw_routes import FirmwareRoutes, RouteError
# Configuration
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = int(os.environ.get("PORT", "8080"))
# v1.12.0: 기본 포트 8080→9090 으로 통일. 설정기는 PC·디바이스 어디서든 :9090 이다
# (디바이스는 systemd 유닛이 PORT=9090 지정 — 동일값이라 dev/prod 포트가 갈리지 않음).
# 디바이스의 :8080 은 별개의 레거시 Java app-runner 이며 이 앱과 무관하다.
PORT = int(os.environ.get("PORT", "9090"))
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
# v1.5.4: MIME prefixes eligible for gzip compression.
@ -493,6 +496,8 @@ class ConfigHandler(BaseHTTPRequestHandler):
elif path == "/api/network/config":
# #2: watchdog kill-switch — 현재 net_config (효과적 기본값 merge) 조회
self._net_json(lambda: _NET.get_config())
elif path == "/api/network/uplink":
self._net_json(lambda: _NET.get_uplink())
# AP: status route
elif path == "/api/network/ap/status":
self._ap_json(lambda: _AP.status())
@ -680,6 +685,14 @@ class ConfigHandler(BaseHTTPRequestHandler):
{"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._net_json(lambda: _NET.config(data))
elif path == "/api/network/uplink":
data = self._read_request_body()
if data is None:
return
if not isinstance(data, dict):
self._send_json_response({"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._net_json(lambda: _NET.post_uplink(data))
# AP: config + apply routes
elif path == "/api/network/ap/config":
data = self._read_request_body()

70
src/static/css/style.css

@ -1704,6 +1704,13 @@ button.dash-issue__action--link:hover { text-decoration: underline; }
.dash-row__lbl { color: var(--text-secondary, #94A3B8); min-width: 96px; }
.dash-row__val { color: var(--text-primary, #E2E8F0); font-family: 'JetBrains Mono', monospace; }
.dash-row__val--dim { color: var(--text-muted, #64748B); font-size: 12px; }
.ap-client-table {
margin-top: .75rem;
}
.ap-client-table__mono {
font-family: 'JetBrains Mono', monospace;
overflow-wrap: anywhere;
}
.dash-row--section {
color: var(--text-muted, #888);
font-size: 0.85em;
@ -1887,6 +1894,7 @@ button.dash-issue__action--link:hover { text-decoration: underline; }
.dash-status-chip--ok { background: var(--success-bg); color: var(--success); }
.dash-status-chip--warn { background: var(--warning-bg); color: var(--warning); }
.dash-status-chip--error { background: var(--error-bg); color: var(--error); }
.dash-status-chip--na { background: rgba(100,116,139,.12); color: var(--text-muted); }
/* ─── Dashboard V2 — Hardware Modules card (v1.11.0) ──────── */
.hw-module + .hw-module { margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--border-default); }
@ -3031,3 +3039,65 @@ button .icon, a .icon, label .icon { pointer-events: none; }
.pending-unapplied__desc { color: var(--text-muted, #94A3B8); font-size: var(--font-size-xs, 12px); margin: 0 0 0.4rem; }
.pending-unapplied__list { margin: 0 0 0.5rem; padding-left: 1.1rem; }
.pending-unapplied__item { font-family: var(--font-mono); font-size: var(--font-size-xs, 12px); }
/*
Telemetry Uplink page (uplink.js selector + honest status)
Route cloud telemetry/RTCM/update over the operator-selected interface.
Tokens only (no new colors). Status colors come from var(--success/warning/danger).
*/
.uplink-card {
border: 1px solid var(--border, rgba(148,163,184,.25));
border-radius: 8px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
background: var(--bg-card, transparent);
}
.uplink-card__title {
margin: 0 0 0.6rem;
font-size: var(--font-size-md, 14px);
font-weight: 600;
color: var(--text-primary, #E2E8F0);
}
.uplink-options { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.uplink-opt {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border, rgba(148,163,184,.25));
border-radius: 6px;
cursor: pointer;
}
.uplink-opt:hover { background: var(--bg-card-hover, rgba(148,163,184,.08)); }
.uplink-opt--disabled { cursor: not-allowed; }
/* Dim only the option label keep the reason note/hint legible (AA). The note
("set the gateway first") is the actionable text on a disabled row, so it must
not be dimmed below readable contrast. */
.uplink-opt--disabled .uplink-opt__label { opacity: 0.55; }
.uplink-opt--disabled:hover { background: transparent; }
.uplink-opt:focus-within { border-color: var(--accent, #6366F1); box-shadow: 0 0 0 2px rgba(99,102,241,.25); }
.uplink-fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.uplink-opt__label { flex: 1; font-size: var(--font-size-sm, 13px); }
.uplink-opt__hint { font-size: var(--font-size-xs, 12px); color: var(--text-muted, #94A3B8); }
.uplink-opt__hint--warn { color: var(--warning, #F59E0B); }
.uplink-opt__note { display: block; font-size: var(--font-size-xs, 12px); color: var(--text-secondary, #CBD5E1); margin-top: 0.25rem; }
/* Honest status rollup color comes from the data-rollup tone (mirrors §6 phrases).
No inferred "working": status TEXT is one of the five spec phrases only; the tone
adds legibility so a failure does not look identical to a success. */
.uplink-status { font-size: var(--font-size-sm, 13px); }
.uplink-status__rollup { margin: 0.5rem 0 0; font-family: var(--font-mono); }
.uplink-status__rollup[data-rollup="ok"] { color: var(--success, #10B981); }
.uplink-status__rollup[data-rollup="warn"] { color: var(--warning, #F59E0B); }
.uplink-status__rollup[data-rollup="error"] { color: var(--error, #EF4444); }
.uplink-status__rollup[data-rollup="na"] { color: var(--text-muted, #94A3B8); }
.uplink-status__msg { margin: 0.4rem 0; font-size: var(--font-size-sm, 13px); }
.uplink-status__msg--loading { color: var(--text-muted, #94A3B8); }
.uplink-status__msg--empty { color: var(--text-muted, #94A3B8); }
.uplink-status__msg--error { color: var(--error, #EF4444); }
.uplink-status__stamp { margin: 0.4rem 0 0; font-size: var(--font-size-xs, 12px); color: var(--text-muted, #94A3B8); }
/* Server role label (TIOT / Update / DGPS) shown beside the IP in the Server column. */
.uplink-target__role { display: inline-block; min-width: 3.2em; margin-right: 0.5rem; font-weight: 600; color: var(--text-secondary, #CBD5E1); }
/* Pre-apply advisories (soft, informational) under the uplink selector. */
#uplink-advisories { margin: 0.4rem 0 0.2rem; }
.uplink-status__msg--warning { color: var(--warning, #F59E0B); }

7
src/static/index.html

@ -89,6 +89,13 @@
<span class="nav-item__label">Server Setting</span>
<span class="nav-item__dirty" id="dirty-server-setting" aria-hidden="true"></span>
</a></li>
<!-- Telemetry Uplink: route cloud telemetry (RTCM/update/protocol) over the
selected interface via per-target /32 host routes. Self-contained Save&Apply. -->
<li><a href="#" class="nav-item nav-item--leaf" data-page="uplink" id="nav-uplink">
<span class="nav-item__icon" id="icon-nav-uplink"></span>
<span class="nav-item__label">Telemetry Uplink</span>
<span class="nav-item__dirty" id="dirty-uplink" aria-hidden="true"></span>
</a></li>
<!-- v1.8.0: Apply & Status 는 Advanced/Debug 보기 전용 (User 모드 숨김). -->
<li class="advanced-only"><a href="#" class="nav-item nav-item--leaf" data-page="net-apply" id="nav-net-apply">
<span class="nav-item__icon" id="icon-nav-net-apply"></span>

42
src/static/js/api.js

@ -7,12 +7,26 @@
const API_BASE = 'setting';
/**
* Fetch with an AbortController timeout. The timer is cleared on completion.
* On timeout the fetch rejects with an AbortError (flows into loadAllData catch).
* @param {string} url
* @param {RequestInit} opts
* @param {number} ms - Timeout in milliseconds (generous default 15 s for slow devices)
* @returns {Promise<Response>}
*/
export function fetchWithTimeout(url, opts = {}, ms = 15000) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort(), ms);
return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => clearTimeout(id));
}
/**
* GET /setting/get-device
* @returns {Promise<Object|null>} Device config or null if not set
*/
export async function getDevice() {
const res = await fetch(`${API_BASE}/get-device`);
const res = await fetchWithTimeout(`${API_BASE}/get-device`);
if (res.status === 204) return null;
if (!res.ok) throw new Error(`Failed to load device config: ${res.status}`);
return res.json();
@ -44,7 +58,7 @@ export async function saveDevice(data) {
* @returns {Promise<Object|null>} Protocol config or null if not set
*/
export async function getProtocol() {
const res = await fetch(`${API_BASE}/get-protocol`);
const res = await fetchWithTimeout(`${API_BASE}/get-protocol`);
if (res.status === 204) return null;
if (!res.ok) throw new Error(`Failed to load protocol config: ${res.status}`);
return res.json();
@ -144,7 +158,7 @@ export async function deleteLogFilesApi(files) {
*/
export async function getMac() {
try {
const res = await fetch('api/mac');
const res = await fetchWithTimeout('api/mac');
if (!res.ok) return null;
const data = await res.json();
return data.mac || null;
@ -256,3 +270,25 @@ export async function restoreCheck() {
if (!res.ok) throw new Error(r.error || `restore-check failed: ${res.status}`);
return r;
}
// ── Telemetry Uplink API (Task 11) ────────────────────────────────────────────
/** GET /api/network/uplink → { uplink_config, options, status } */
export async function getUplink() {
const res = await fetch('api/network/uplink');
const r = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(r.error || `uplink load failed: ${res.status}`);
return r;
}
/** POST /api/network/uplink {telemetry_iface} → { ok, state, apply_id } */
export async function postUplink(body) {
const res = await fetch('api/network/uplink', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
const r = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(r.error || `uplink apply failed: ${res.status}`);
return r;
}

26
src/static/js/app.js

@ -12,14 +12,10 @@ import { state, setDevice, setProtocol, buildDevicePayload, buildProtocolPayload
import { clearAllFieldErrors, setPageError, clearAllPageErrors, focusErrorField } from './validator.js';
import { getDirtyPages, clearAllDirty, markDirty } from './page-dirty.js';
import { attachDirtyTracking } from './dirty-tracker.js';
import { renderSsidPage } from './pages/ssid.js';
import ssidPage from './pages/ssid.js';
// v1.5.0 P2: wifi.js (ssid.js rename)
import wifiPage from './pages/wifi.js';
// v1.5.0 P2 T2: ethernet.js NEW (eth + LTE interface IP)
import ethernetPage from './pages/ethernet.js';
import { renderNetworkPage } from './pages/network.js';
import networkPage from './pages/network.js';
// v1.5.0 P2 T3: server-setting.js (network.js rename)
import serverSettingPage from './pages/server-setting.js';
import { renderIoPage } from './pages/io.js';
@ -46,6 +42,8 @@ import homePage, { setNavigate as setHomeNavigate } from './pages/home.js';
// v1.6.0: Network Apply & Status page
import netApplyPage from './pages/net-apply.js';
import wifiApPage from './pages/wifi-ap.js'; // AP: page import
// Telemetry Uplink: per-target /32 host routes for cloud telemetry/RTCM/update.
import uplinkPage from './pages/uplink.js';
// v1.5.0 P1: Lucide SVG icons + sidebar nested
import { icon } from './icons.js';
// v1.5.0 P1: nav-guard — navigation dirty modal
@ -62,8 +60,6 @@ const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.
let _deviceMac = null;
const PAGE_RENDERERS = {
ssid: renderSsidPage,
network: renderNetworkPage,
io: renderIoPage,
log: renderLogPage,
register: renderRegisterPage,
@ -92,6 +88,7 @@ const PAGES = {
opcua: opcuaPage,
firmware: firmwarePage,
'net-apply': netApplyPage, // v1.6.0: Network Apply & Status
uplink: uplinkPage, // Telemetry Uplink — self-contained Save&Apply
};
let activePage = null;
let detachDirtyTracking = null;
@ -155,6 +152,7 @@ function injectSidebarIcons() {
'icon-nav-firmware': { name: 'package', size: 18 },
'icon-nav-net-apply': { name: 'plug-zap', size: 16 }, // v1.6.0
'icon-nav-wifi-ap': { name: 'wifi', size: 16 }, // AP: nav icon
'icon-nav-uplink': { name: 'upload', size: 16 }, // Telemetry Uplink (distinct from can-bus 'route')
};
Object.entries(map).forEach(([id, spec]) => {
const el = document.getElementById(id);
@ -284,7 +282,8 @@ document.addEventListener('DOMContentLoaded', async () => {
});
// ─── Data Loading ───────────────────────────────────────────
async function loadAllData() {
// Exported for direct test access (avoids needing to fire DOMContentLoaded).
export async function loadAllData() {
try {
if (DEBUG) console.log('[App] Loading data from API...');
const [device, protocol, mac] = await Promise.all([
@ -299,11 +298,15 @@ async function loadAllData() {
setDevice(device);
setProtocol(protocol);
state.isDirty = false;
// B6: clear the guard flag — a successful load means Save All is safe.
state.configLoadFailed = false;
if (DEBUG) console.log('[App] Data loaded. state.device:', state.device);
if (DEBUG) console.log('[App] Data loaded. state.protocol:', state.protocol);
} catch (e) {
console.error('[App] Failed to load config:', e);
showToast('Failed to load configuration. Check server connection.', 'error');
// B6: set the guard flag — Save All must not run with empty defaults.
state.configLoadFailed = true;
// Use defaults
setDevice(null);
setProtocol(null);
@ -546,6 +549,15 @@ export async function handleSaveAll({ skipConfirm = false } = {}) {
// Prevent concurrent save operations
if (_saving) return false;
// B6: refuse to save when the initial config load failed — empty defaults would
// overwrite the real DB. Cleared by a successful loadAllData().
if (state.configLoadFailed) {
showToast('Configuration failed to load — reload the page before saving.', 'error');
const btn = document.getElementById('btn-save-all');
if (btn) btn.disabled = true;
return false;
}
// v1.5.0 P4b T3: Show confirm modal listing dirty pages if pageDirty matrix available
if (!skipConfirm) {
const dirtyPages = getDirtyPages();

9
src/static/js/components/crud-table.js

@ -34,9 +34,16 @@ function addRow(tbody, columns, data) {
const cells = columns.map(col => {
const fieldKey = escapeHtml(col.key);
if (col.type === 'select') {
const stored = data[col.key];
// If the stored value is non-empty and not in the static option list (e.g. a
// legacy token or casing drift), prepend it as a selected option so it round-trips
// losslessly instead of silently falling to the first option on save.
const unknownOption = (stored != null && stored !== '' && !col.options.includes(stored))
? `<option value="${escapeHtml(stored)}" selected>${escapeHtml(stored)}</option>`
: '';
return `<td>
<select class="form-select form-select--sm" data-field="${fieldKey}">
${col.options.map(v => `<option value="${escapeHtml(v)}" ${data[col.key] === v ? 'selected' : ''}>${escapeHtml(v)}</option>`).join('')}
${unknownOption}${col.options.map(v => `<option value="${escapeHtml(v)}" ${stored === v ? 'selected' : ''}>${escapeHtml(v)}</option>`).join('')}
</select>
</td>`;
}

6
src/static/js/confirm-modal.js

@ -20,11 +20,7 @@
* Focus is trapped inside the dialog and restored to the trigger on close.
*/
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}
import { escapeHtml } from './utils.js';
/**
* @param {Object} opts

3
src/static/js/constants.js

@ -9,7 +9,7 @@
* Bump APP_VERSION on every release and add a matching CHANGELOG.md entry.
*/
export const APP_NAME = 'DP World Smart Solutions';
export const APP_VERSION = 'v1.11.16';
export const APP_VERSION = 'v1.12.1';
/**
* v1.7.0 pageId 표시명 (Pending Changes 패널·배지에서 사용).
@ -34,6 +34,7 @@ export const PAGE_DISPLAY_NAMES = {
log: 'Log',
firmware: 'Firmware',
'net-apply': 'Apply & Status',
uplink: 'Telemetry Uplink',
};
/**

1
src/static/js/icons.js

@ -73,6 +73,7 @@ const ICONS = {
// Status
'check-circle-2': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
'x-circle': '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'activity': '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
'hourglass': '<path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>',

11
src/static/js/nav-guard.js

@ -18,6 +18,7 @@
import { state, discardPageChanges } from './state.js';
import { clearDirty } from './page-dirty.js';
import { showToast } from './toast.js';
import { escapeHtml } from './utils.js';
/**
* @param {string} currentPageId source page id (이동 )
@ -58,7 +59,7 @@ function _buildModal(currentPageId, resolve) {
<div class="nav-guard__modal">
<h2 id="nav-guard-title" class="nav-guard__title">Unsaved changes</h2>
<p class="nav-guard__desc">
You have unsaved changes on <strong>${_escape(currentPageId)}</strong>.
You have unsaved changes on <strong>${escapeHtml(currentPageId)}</strong>.
What would you like to do?
</p>
<div class="nav-guard__actions">
@ -131,6 +132,9 @@ function _buildModal(currentPageId, resolve) {
const escHandler = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
// Ignore Esc while save is in flight — saveBtn is disabled during the await.
const saveBtn = wrap.querySelector('[data-action="save"]');
if (saveBtn && saveBtn.disabled) return;
close(false);
}
};
@ -159,8 +163,3 @@ function _trapFocus(modalRoot) {
});
}
function _escape(s) {
return String(s).replace(/[&<>"']/g, ch => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}

6
src/static/js/pages/firmware.js

@ -183,6 +183,9 @@ function updateFlashArea(s, view) {
if (view === 'rebooted') { ensureFlashMode('rebooted', body, s); return; }
if (view === 'done') { ensureFlashMode('done', body, s); return; }
ensureFlashMode('progress', body, s); // flashing | failed
// review B7: on a flashing→failed transition ensureFlashMode early-returns (flashMode
// already 'progress'), leaving the "being written" banner up. Replace it for the failed case.
if (view === 'failed') body.querySelector('.fw-danger')?.replaceChildren('Flash failed — see the log before retrying.');
updateFlashProgress(s, view);
}
@ -700,7 +703,10 @@ async function doFlash() {
log('flash started — backing up config, then transferring');
poll(); // advance the wizard to the Flash step immediately
} catch (err) {
// review B24: flash never started, so the prior pre-flight result is still valid.
// Reset preflightOk=true so updateNavState() keeps Flash enabled for the retry.
if (btn) btn.disabled = false;
preflightOk = true;
showToast('Could not start flash — ' + err.message, 'error');
log('flash start failed: ' + err.message);
}

20
src/static/js/pages/home.js

@ -32,6 +32,10 @@ let _gridEl = null; // v1.7.1 Task 2: stable grid element for delegated deep-li
// re-renders the grid (innerHTML) producing a fresh ENABLED restart button while a
// restart is still running — without this flag a second gesture re-enters handleRestart.
let _restartInFlight = false;
// review B23: monotonically-increasing epoch to discard out-of-order poll responses.
// Each poll() call captures the current epoch and checks it after await; if a newer
// poll has already committed its result, the older resolve is a no-op.
let _pollEpoch = 0;
const FRESHNESS_CLOCK_SKEW_MS = 10 * 60 * 1000;
@ -975,13 +979,19 @@ function renderStaleBanner() {
// ─── Polling ──────────────────────────────────────────────────
async function poll() {
const myEpoch = ++_pollEpoch;
try {
lastStatus = await getSystemStatus();
const status = await getSystemStatus();
// review B23: discard out-of-order responses — a newer poll has already committed.
if (myEpoch !== _pollEpoch) return;
lastStatus = status;
_lastReceivedAt = Date.now();
_stale = false;
_lastError = '';
renderCards();
} catch (err) {
// review B23: also guard the error path — a newer poll may have succeeded.
if (myEpoch !== _pollEpoch) return;
// v1.7.1 Task 1: zombie guard — never silently keep the last "healthy"
// render. Mark stale and re-render so the banner + freshness reflect reality.
_stale = true;
@ -1510,10 +1520,11 @@ export function dashboardHeaderSummary(status) {
*/
function overallStatusChip(status, stale) {
const issues = collectDashboardIssues(status) || [];
let critical = 0, warning = 0;
for (const it of issues) { const t = cleanTier(it.tier); if (t === 'error') critical++; else if (t === 'warn') warning++; }
let critical = 0, warning = 0, unknown = 0;
for (const it of issues) { const t = cleanTier(it.tier); if (t === 'error') critical++; else if (t === 'warn') warning++; else if (t === 'na') unknown++; }
if (stale && !critical) return `<span class="dash-status-chip dash-status-chip--warn">${icon('alert-triangle', { size: 13 })} Status may be stale</span>`;
if (!critical && !warning) return `<span class="dash-status-chip dash-status-chip--ok">${icon('check-circle-2', { size: 13 })} All systems operational</span>`;
if (!critical && !warning && !unknown) return `<span class="dash-status-chip dash-status-chip--ok">${icon('check-circle-2', { size: 13 })} All systems operational</span>`;
if (!critical && !warning) return `<span class="dash-status-chip dash-status-chip--na">${icon('help-circle', { size: 13 })} Status incomplete</span>`;
const tier = critical ? 'error' : 'warn';
const parts = []; if (critical) parts.push(`${critical} critical`); if (warning) parts.push(`${warning} warning`);
return `<span class="dash-status-chip dash-status-chip--${tier}">${icon('alert-triangle', { size: 13 })} Attention needed · ${parts.join(', ')}</span>`;
@ -1890,6 +1901,7 @@ const homePage = {
_lastError = '';
_lastReceivedAt = null;
_restartInFlight = false;
_pollEpoch = 0;
},
};

19
src/static/js/pages/log.js

@ -559,25 +559,6 @@ async function handleDelete() {
}
}
// ─── Kernel Logs Section ─────────────────────────────────────
function renderKernelLogsSection() {
const section = document.createElement('div');
section.className = 'card';
section.innerHTML = `
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">${icon('terminal-square', {size:16})}</span> Kernel Logs</h2>
</div>
<p class="form-hint">Bundle systemd journal exports and kernel log files for diagnostic download. Includes <code>kernel.log</code>, <code>full-journal.log</code>, <code>kernel-follow.log</code>, boot history, pstore crash dumps, and a manifest.</p>
<button id="kernel_bundle_btn" class="btn btn--primary">
Download Kernel Log Bundle
</button>
`;
section.querySelector('#kernel_bundle_btn')
.addEventListener('click', onDownloadKernelBundle);
return section;
}
async function onDownloadKernelBundle(event) {
const btn = event.currentTarget;
const original = btn.textContent.trim();

207
src/static/js/pages/network.js

@ -1,207 +0,0 @@
/**
* network.js Network Configuration Page
*
* Server endpoint settings (IP + Port for all server types).
* Uses shared IP octet component.
*/
import { state, bindIpGroup, bindInput } from '../state.js';
import { escapeHtml } from '../utils.js';
import { isValidIP, isValidPort } from '../validator.js';
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js';
import { setupPortInputs } from '../components/port-input.js';
export function renderNetworkPage(container) {
const device = state.device || {};
const server = device.server || {};
const servers = [
{ label: 'TIOT Server', ipKey: 'protocol_server_ip', portKey: 'protocol_server_port' },
{ label: 'Update Server', ipKey: 'update_server_ip', portKey: 'update_server_port' },
{ label: 'DGPS Server', ipKey: 'rtcm_server_ip', portKey: 'rtcm_server_port' },
{ label: 'OPC-UA Server', ipKey: 'opc_ua_server_ip', portKey: 'opc_ua_server_port' },
{ label: 'Modbus Server', ipKey: 'modbus_server_ip', portKey: 'modbus_server_port' },
{ label: 'LTE Server', ipKey: 'lte_server_ip', portKey: 'lte_server_port' },
];
container.innerHTML = `
<div class="page-header">
<h1 class="page-header__title">🌐 Network Settings</h1>
<p class="page-header__desc">Configure IP addresses and ports for each server.</p>
</div>
<div class="card" style="width: fit-content; margin-left: auto; margin-right: auto;">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🖥</span> Server Endpoints</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table" style="width: auto;">
<thead>
<tr>
<th style="width: 160px;">Server Type</th>
<th>IP Address</th>
<th style="width: 100px;">Port</th>
</tr>
</thead>
<tbody>
${servers.map(s => `
<tr>
<td style="font-weight: 500; color: var(--text-primary);">${s.label}</td>
<td>${renderIpOctets(s.ipKey, server[s.ipKey] || '')}</td>
<td><input class="form-input form-input--mono" id="${s.portKey}" value="${escapeHtml(server[s.portKey])}" placeholder="0"></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
// Setup IP octet event listeners
setupIpOctets(container);
setupPortInputs(container);
// Register data collector
window.__pageCollectors.network = collectNetworkData;
}
function collectNetworkData() {
if (!state.device) state.device = {};
const ipKeys = [
'protocol_server_ip', 'update_server_ip', 'rtcm_server_ip',
'opc_ua_server_ip', 'modbus_server_ip', 'lte_server_ip',
];
const portKeys = [
'protocol_server_port', 'update_server_port', 'rtcm_server_port',
'opc_ua_server_port', 'modbus_server_port', 'lte_server_port',
];
const server = {};
ipKeys.forEach(ipKey => {
server[ipKey] = collectIpValue(ipKey);
});
portKeys.forEach(key => {
const el = document.getElementById(key);
if (el) {
server[key] = el.value.trim();
}
});
state.device.server = server;
}
// ─── Server definitions (shared) ────────────────────────────
const SERVERS = [
{ label: 'TIOT Server', ipKey: 'protocol_server_ip', portKey: 'protocol_server_port' },
{ label: 'Update Server', ipKey: 'update_server_ip', portKey: 'update_server_port' },
{ label: 'DGPS Server', ipKey: 'rtcm_server_ip', portKey: 'rtcm_server_port' },
{ label: 'OPC-UA Server', ipKey: 'opc_ua_server_ip', portKey: 'opc_ua_server_port' },
{ label: 'Modbus Server', ipKey: 'modbus_server_ip', portKey: 'modbus_server_port' },
{ label: 'LTE Server', ipKey: 'lte_server_ip', portKey: 'lte_server_port' },
];
// ─── New Page Interface ─────────────────────────────────────
const networkPage = {
render(container) {
const device = state.device || {};
const server = device.server || {};
container.innerHTML = `
<div class="page-header">
<h1 class="page-header__title">🌐 Network Settings</h1>
<p class="page-header__desc">Configure IP addresses and ports for each server.</p>
</div>
<div class="card" style="width: fit-content; margin-left: auto; margin-right: auto;">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🖥</span> Server Endpoints</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table" style="width: auto;">
<thead>
<tr>
<th style="width: 160px;">Server Type</th>
<th>IP Address</th>
<th style="width: 100px;">Port</th>
</tr>
</thead>
<tbody>
${SERVERS.map(s => `
<tr>
<td style="font-weight: 500; color: var(--text-primary);">${s.label}</td>
<td>${renderIpOctets(s.ipKey, server[s.ipKey] || '')}</td>
<td><input class="form-input form-input--mono" id="${s.portKey}" value="${escapeHtml(server[s.portKey])}" placeholder="0"></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
},
mount(container) {
const server = state.device?.server || {};
// Setup IP octet event listeners (auto-tab, paste, numeric enforcement)
setupIpOctets(container);
setupPortInputs(container);
// Reactive bindings: IP groups → state
SERVERS.forEach(s => {
bindIpGroup(container, s.ipKey,
() => server[s.ipKey] || '',
(val) => {
if (!state.device) state.device = {};
if (!state.device.server) state.device.server = {};
state.device.server[s.ipKey] = val;
state.isDirty = true;
}
);
});
// Reactive bindings: Port inputs → state
SERVERS.forEach(s => {
bindInput(container, `#${s.portKey}`,
() => server[s.portKey] || '',
(val) => {
if (!state.device) state.device = {};
if (!state.device.server) state.device.server = {};
state.device.server[s.portKey] = val.trim();
state.isDirty = true;
}
);
});
// Register legacy collector (R4: __pageCollectors 유지)
window.__pageCollectors.network = collectNetworkData;
},
destroy() {
// No cleanup needed — DOM is replaced by showPage()
},
validate() {
const errors = [];
const server = state.device?.server || {};
SERVERS.forEach(s => {
const ip = server[s.ipKey];
if (ip && ip !== '...' && !isValidIP(ip)) {
errors.push({ field: s.ipKey, page: 'network', message: `${s.label} — Invalid IP format` });
}
const port = server[s.portKey];
if (port && !isValidPort(port)) {
errors.push({ field: s.portKey, page: 'network', message: `${s.label} Port — Must be 1–65535` });
}
});
return errors;
},
};
export default networkPage;

4
src/static/js/pages/register.js

@ -228,7 +228,7 @@ export function renderRegisterPage(container) {
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Expression</label>
<input type="text" class="form-input" id="reg-odo_speed_expr" value="${escapeHtml(p.odo_speed?.expr || '')}" placeholder="x*1000" maxlength="256">
<input type="text" class="form-input" id="reg-odo_speed_expr" value="${escapeHtml(p.odo_speed?.expr || '')}" placeholder="x*1000" maxlength="39">
</div>
</div>
@ -258,7 +258,7 @@ export function renderRegisterPage(container) {
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Expression</label>
<input type="text" class="form-input" id="reg-odo_direction_expr" value="${escapeHtml(p.odo_direction?.expr || '')}" placeholder="x-7" maxlength="256">
<input type="text" class="form-input" id="reg-odo_direction_expr" value="${escapeHtml(p.odo_direction?.expr || '')}" placeholder="x-7" maxlength="39">
</div>
</div>
</div>

2
src/static/js/pages/sensor-io.js

@ -51,7 +51,7 @@ const sensorIoPage = {
<div class="form-group">
<label class="form-label">Parity</label>
<select class="form-select" id="rs485_parity">
<option value="no" ${rs485.parity !== 'even' && rs485.parity !== 'odd' ? 'selected' : ''}>None</option>
<option value="none" ${rs485.parity !== 'even' && rs485.parity !== 'odd' ? 'selected' : ''}>None</option>
<option value="even" ${rs485.parity === 'even' ? 'selected' : ''}>Even</option>
<option value="odd" ${rs485.parity === 'odd' ? 'selected' : ''}>Odd</option>
</select>

458
src/static/js/pages/ssid.js

@ -1,458 +0,0 @@
/**
* ssid.js SSID Configuration Page
*
* Manages WiFi SSID list and WiFi connection settings (static/DHCP).
* IP fields use shared octet-split component.
*/
import { state, bindIpGroup, bindRadio } from '../state.js';
import { escapeHtml } from '../utils.js';
import { isValidIP, isValidCountryCode, normalizeCountryCode } from '../validator.js';
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js';
import { DEFAULTS } from '../constants.js';
import { COUNTRY_CODES, CONTINENT_GROUPS, FREQUENT_COUNTRY_CODES } from '../country-codes.js';
const MAX_SSID_COUNT = 10;
/**
* Build the grouped <optgroup>/<option> markup for the Country Code <select>:
* a pinned "Frequently used" group above five continent groups (205 codes).
* `selected` is the current stored code; if it is not a recognized code (an
* empty value, or a stale "EU" from the old free-text field), the dropdown
* falls back to the AE default. The `selected` attribute is applied to the
* first occurrence only, so exactly one option is selected.
*/
function renderCountryOptions(selected) {
const want = (selected || DEFAULTS.WIFI_COUNTRY_CODE).toUpperCase();
const sel = COUNTRY_CODES[want] ? want : DEFAULTS.WIFI_COUNTRY_CODE;
let selPlaced = false;
const option = (code) => {
const isSel = !selPlaced && code === sel;
if (isSel) selPlaced = true;
return `<option value="${code}"${isSel ? ' selected' : ''}>` +
`${escapeHtml(code + ' — ' + COUNTRY_CODES[code])}</option>`;
};
const group = (label, codes) =>
`<optgroup label="${escapeHtml(label)}">` +
codes.map(option).join('') +
`</optgroup>`;
let html = group('Frequently used', FREQUENT_COUNTRY_CODES);
html += CONTINENT_GROUPS.map(g => group(g.region, g.codes)).join('');
return html;
}
export function renderSsidPage(container) {
const device = state.device || {};
const wifi = device.wifi || {};
const ssidList = device.ssid_list || [];
container.innerHTML = `
<div class="page-header">
<h1 class="page-header__title">📶 WiFi SSID Management</h1>
<p class="page-header__desc">Manage WiFi network list and connection settings.</p>
</div>
<!-- SSID List -->
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">📡</span> SSID List</h2>
<button class="btn btn--outline btn--sm" id="ssid-add-btn"> Add</button>
</div>
<div class="data-table-wrapper">
<table class="data-table" id="ssid-table">
<thead>
<tr>
<th>SSID Name</th>
<th>Password</th>
<th>Security</th>
<th class="col-action"></th>
</tr>
</thead>
<tbody id="ssid-tbody"></tbody>
</table>
</div>
<p class="form-hint mt-sm">Up to 10 SSIDs can be registered.</p>
</div>
<!-- WiFi Connection Settings -->
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🔧</span> WiFi Connection Settings</h2>
</div>
<div class="form-group">
<label class="form-label">Connection Type</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="wifi_type" value="static" ${wifi.type !== 'dhcp' ? 'checked' : ''}>
Static
</label>
<label class="radio-label">
<input type="radio" name="wifi_type" value="dhcp" ${wifi.type === 'dhcp' ? 'checked' : ''}>
DHCP
</label>
</div>
</div>
<div id="wifi-static-fields">
<div class="form-row">
<div class="form-group">
<label class="form-label">IP Address</label>
${renderIpOctets('wifi_ip', wifi.ip)}
</div>
<div class="form-group">
<label class="form-label">Subnet Mask</label>
${renderIpOctets('wifi_netmask', wifi.netmask)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Gateway</label>
${renderIpOctets('wifi_gateway', wifi.gateway)}
</div>
<div class="form-group">
<label class="form-label">DNS 1</label>
${renderIpOctets('wifi_dns1', wifi.dns1)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">DNS 2</label>
${renderIpOctets('wifi_dns2', wifi.dns2)}
</div>
<div class="form-group"></div>
</div>
</div>
</div>
`;
// Setup IP octet event listeners
setupIpOctets(container);
// Populate SSID rows
const tbody = document.getElementById('ssid-tbody');
ssidList.forEach((s, i) => addSsidRow(tbody, s, i));
// Add SSID button (max 10 rows)
const addBtn = document.getElementById('ssid-add-btn');
addBtn.addEventListener('click', () => {
if (tbody.querySelectorAll('tr').length >= MAX_SSID_COUNT) return;
addSsidRow(tbody, { ssid: '', password: '', security: 'wpa/wpa2' }, ssidList.length);
updateSsidAddButton();
});
updateSsidAddButton();
// Static/DHCP toggle
document.querySelectorAll('input[name="wifi_type"]').forEach(radio => {
radio.addEventListener('change', () => toggleStaticFields());
});
toggleStaticFields();
// Register data collector
window.__pageCollectors.ssid = collectSsidData;
}
function addSsidRow(tbody, data, index) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input class="form-input" data-field="ssid" value="${escapeHtml(data.ssid)}" placeholder="SSID Name"></td>
<td>
<div class="input-password">
<input class="form-input" type="password" data-field="password" value="${escapeHtml(data.password)}" placeholder="Password">
<button class="input-password__toggle" type="button" title="Show password">👁</button>
</div>
</td>
<td>
<select class="form-select" data-field="security">
<option value="wpa/wpa2" ${data.security === 'wpa/wpa2' || data.security === 'WPA' ? 'selected' : ''}>WPA/WPA2</option>
<option value="WEP" ${data.security === 'WEP' ? 'selected' : ''}>WEP</option>
<option value="Open" ${data.security === 'Open' ? 'selected' : ''}>Open</option>
</select>
</td>
<td class="col-action">
<button class="btn btn--ghost btn--icon ssid-delete" title="Delete">🗑</button>
</td>
`;
// Toggle password visibility
tr.querySelector('.input-password__toggle').addEventListener('click', (e) => {
const input = tr.querySelector('input[data-field="password"]');
input.type = input.type === 'password' ? 'text' : 'password';
e.target.textContent = input.type === 'password' ? '👁' : '🔒';
});
// Delete row
tr.querySelector('.ssid-delete').addEventListener('click', () => {
tr.remove();
updateSsidAddButton();
});
tbody.appendChild(tr);
}
function updateSsidAddButton() {
const btn = document.getElementById('ssid-add-btn');
const count = document.querySelectorAll('#ssid-tbody tr').length;
if (btn) btn.disabled = count >= MAX_SSID_COUNT;
}
function toggleStaticFields() {
const isDhcp = document.querySelector('input[name="wifi_type"]:checked')?.value === 'dhcp';
const fields = document.getElementById('wifi-static-fields');
if (!fields) return;
fields.querySelectorAll('.form-input').forEach(input => {
input.disabled = isDhcp;
});
fields.style.opacity = isDhcp ? '0.5' : '1';
}
function collectSsidData() {
if (!state.device) state.device = {};
// WiFi settings — collect from octet groups.
// country_code MUST be re-collected here: this object replaces state.device.wifi
// wholesale, so omitting it would silently drop the legacy Java value on save.
const wifiType = document.querySelector('input[name="wifi_type"]:checked')?.value || 'static';
const ccEl = document.getElementById('wifi_country_code');
const countryCode = normalizeCountryCode(
ccEl ? ccEl.value : (state.device.wifi && state.device.wifi.country_code)
) || DEFAULTS.WIFI_COUNTRY_CODE;
state.device.wifi = {
type: wifiType,
ip: collectIpValue('wifi_ip'),
netmask: collectIpValue('wifi_netmask'),
gateway: collectIpValue('wifi_gateway'),
dns1: collectIpValue('wifi_dns1'),
dns2: collectIpValue('wifi_dns2'),
country_code: countryCode,
};
// SSID list
const rows = document.querySelectorAll('#ssid-tbody tr');
state.device.ssid_list = Array.from(rows).map(tr => ({
ssid: tr.querySelector('[data-field="ssid"]')?.value || '',
password: tr.querySelector('[data-field="password"]')?.value || '',
security: tr.querySelector('[data-field="security"]')?.value || 'wpa/wpa2',
}));
}
// ─── WiFi IP fields ─────────────────────────────────────────
const WIFI_IP_FIELDS = ['wifi_ip', 'wifi_netmask', 'wifi_gateway', 'wifi_dns1', 'wifi_dns2'];
const WIFI_IP_LABELS = {
wifi_ip: 'WiFi IP', wifi_netmask: 'WiFi Subnet Mask', wifi_gateway: 'WiFi Gateway',
wifi_dns1: 'WiFi DNS 1', wifi_dns2: 'WiFi DNS 2',
};
const WIFI_STATE_KEYS = { wifi_ip: 'ip', wifi_netmask: 'netmask', wifi_gateway: 'gateway', wifi_dns1: 'dns1', wifi_dns2: 'dns2' };
// ─── New Page Interface ─────────────────────────────────────
const ssidPage = {
render(container) {
const device = state.device || {};
const wifi = device.wifi || {};
const ssidList = device.ssid_list || [];
container.innerHTML = `
<div class="page-header">
<h1 class="page-header__title">📶 WiFi SSID Management</h1>
<p class="page-header__desc">Manage WiFi network list and connection settings.</p>
</div>
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">📡</span> SSID List</h2>
<button class="btn btn--outline btn--sm" id="ssid-add-btn"> Add</button>
</div>
<div class="data-table-wrapper">
<table class="data-table" id="ssid-table">
<thead>
<tr>
<th>SSID Name</th>
<th>Password</th>
<th>Security</th>
<th class="col-action"></th>
</tr>
</thead>
<tbody id="ssid-tbody"></tbody>
</table>
</div>
<p class="form-hint mt-sm">Up to 10 SSIDs can be registered.</p>
</div>
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🔧</span> WiFi Connection Settings</h2>
</div>
<div class="form-group">
<label class="form-label">Connection Type</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="wifi_type" value="static" ${wifi.type !== 'dhcp' ? 'checked' : ''}>
Static
</label>
<label class="radio-label">
<input type="radio" name="wifi_type" value="dhcp" ${wifi.type === 'dhcp' ? 'checked' : ''}>
DHCP
</label>
</div>
</div>
<div id="wifi-static-fields">
<div class="form-row">
<div class="form-group">
<label class="form-label">IP Address</label>
${renderIpOctets('wifi_ip', wifi.ip)}
</div>
<div class="form-group">
<label class="form-label">Subnet Mask</label>
${renderIpOctets('wifi_netmask', wifi.netmask)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Gateway</label>
${renderIpOctets('wifi_gateway', wifi.gateway)}
</div>
<div class="form-group">
<label class="form-label">DNS 1</label>
${renderIpOctets('wifi_dns1', wifi.dns1)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">DNS 2</label>
${renderIpOctets('wifi_dns2', wifi.dns2)}
</div>
<div class="form-group"></div>
</div>
</div>
</div>
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🌍</span> Region</h2>
</div>
<div class="form-group">
<label class="form-label" for="wifi_country_code">Country Code</label>
<select class="form-input" id="wifi_country_code" style="max-width: 300px;">
${renderCountryOptions(wifi.country_code || DEFAULTS.WIFI_COUNTRY_CODE)}
</select>
<p class="form-hint">WiFi regulatory region. Defaults to AE (United Arab Emirates); dpworldapp applies it to the WiFi module.</p>
</div>
</div>
`;
},
mount(container) {
setupIpOctets(container);
// Reactive bindings: WiFi type radio
bindRadio(container, 'wifi_type',
() => (state.device?.wifi?.type || 'static'),
(val) => {
if (!state.device) state.device = {};
if (!state.device.wifi) state.device.wifi = {};
state.device.wifi.type = val;
state.isDirty = true;
}
);
// Reactive bindings: WiFi IP groups
WIFI_IP_FIELDS.forEach(ipKey => {
const stateKey = WIFI_STATE_KEYS[ipKey];
bindIpGroup(container, ipKey,
() => state.device?.wifi?.[stateKey] || '',
(val) => {
if (!state.device) state.device = {};
if (!state.device.wifi) state.device.wifi = {};
state.device.wifi[stateKey] = val;
state.isDirty = true;
}
);
});
// Reactive binding: WiFi country code (<select>)
const ccEl = container.querySelector('#wifi_country_code');
if (ccEl) {
ccEl.addEventListener('change', () => {
if (!state.device) state.device = {};
if (!state.device.wifi) state.device.wifi = {};
state.device.wifi.country_code = ccEl.value;
state.isDirty = true;
});
}
// SSID list: populate + event delegation
const tbody = container.querySelector('#ssid-tbody');
const ssidList = state.device?.ssid_list || [];
ssidList.forEach((s, i) => addSsidRow(tbody, s, i));
// SSID list: reactive sync on input/change/delete
tbody.addEventListener('input', () => { this._syncSsidList(tbody); });
tbody.addEventListener('change', () => { this._syncSsidList(tbody); });
// Sync after row deletion (DOM mutation via click on delete button)
tbody.addEventListener('click', (e) => {
if (e.target.closest('.ssid-delete')) {
// Delay to let the row be removed first
setTimeout(() => { this._syncSsidList(tbody); updateSsidAddButton(); }, 0);
}
});
// Add button
const addBtn = container.querySelector('#ssid-add-btn');
addBtn.addEventListener('click', () => {
if (tbody.querySelectorAll('tr').length >= MAX_SSID_COUNT) return;
addSsidRow(tbody, { ssid: '', password: '', security: 'wpa/wpa2' }, tbody.querySelectorAll('tr').length);
this._syncSsidList(tbody);
updateSsidAddButton();
});
updateSsidAddButton();
// Static/DHCP toggle
container.querySelectorAll('input[name="wifi_type"]').forEach(radio => {
radio.addEventListener('change', () => toggleStaticFields());
});
toggleStaticFields();
// Legacy collector (R4)
window.__pageCollectors.ssid = collectSsidData;
},
_syncSsidList(tbody) {
if (!state.device) state.device = {};
const rows = tbody.querySelectorAll('tr');
state.device.ssid_list = Array.from(rows).map(tr => ({
ssid: tr.querySelector('[data-field="ssid"]')?.value || '',
password: tr.querySelector('[data-field="password"]')?.value || '',
security: tr.querySelector('[data-field="security"]')?.value || 'wpa/wpa2',
}));
state.isDirty = true;
},
destroy() {},
validate() {
const errors = [];
const wifi = state.device?.wifi || {};
// Only validate IP fields when static mode
if (wifi.type !== 'dhcp') {
WIFI_IP_FIELDS.forEach(ipKey => {
const stateKey = WIFI_STATE_KEYS[ipKey];
const val = wifi[stateKey];
if (val && val !== '...' && !isValidIP(val)) {
errors.push({ field: ipKey, page: 'ssid', message: `${WIFI_IP_LABELS[ipKey]} — Invalid IP format` });
}
});
}
// Region — WiFi country code: required, exactly 2 uppercase letters
if (!isValidCountryCode(normalizeCountryCode(wifi.country_code))) {
errors.push({
field: 'wifi_country_code', page: 'ssid',
message: 'Country Code — must be a 2-letter ISO 3166-1 alpha-2 code (e.g. KR, US, DE, JP, AE)',
});
}
return errors;
},
};
export default ssidPage;

347
src/static/js/pages/uplink.js

@ -0,0 +1,347 @@
// src/static/js/pages/uplink.js — Telemetry Uplink leaf (Task 11).
//
// Routes the cloud telemetry/update/RTCM traffic over the operator-selected
// interface (wlan0 default · eth0 LTE · eth1 LAN) via per-target /32 host
// routes — relay/dpworldapp untouched. Self-contained Save&Apply, route-only
// (not disruptive). All status text honours the honesty invariant: NO inferred
// "working"; only the five rollup codes per spec are surfaced (color added for
// legibility, wording unchanged).
import { showToast } from '../toast.js';
import { confirmModal } from '../confirm-modal.js';
import { escapeHtml } from '../utils.js';
import { icon } from '../icons.js';
import { getUplink, postUplink } from '../api.js';
import { clearDirty } from '../page-dirty.js';
const _IFACE_LABELS = {
wlan0: 'Wi-Fi (wlan0)', // default path — labelled by iface, not "current" (it may not be selected)
eth0: 'Ethernet · LTE (eth0)',
eth1: 'Ethernet · LAN (eth1)',
};
const _DEFAULT_IFACE = 'wlan0'; // the system-default-route option (no per-server host-routes)
// Short reason shown as the hint on a DISABLED option.
const _REASON_LABELS = {
no_gateway: 'No gateway configured',
};
// Secondary remediation note under a disabled option.
const _DISABLED_REASONS = {
no_gateway: 'Set a gateway on the matching Ethernet page first.',
};
// Switching the uplink installs/removes routes immediately, but dpworldapp keeps its existing
// TCP connections on the old path until they reconnect — the switch only takes full effect once
// dpworldapp reconnects. This shared phrase is the operator action to apply it right away.
const _RESTART_ACTION = 'restart dpworldapp or reboot the device';
// Backend returns language-neutral rollup codes; map to English (UI is English per v1.11.3).
const _ROLLUP_TEXT = {
'unset': 'Using the system default route (no interface override set).',
'pending': `Applied — ${_RESTART_ACTION} to switch now; existing connections move over when they reconnect.`,
'egress': 'Confirmed — outgoing traffic observed on the selected interface.',
// Backend returns "unreachable" when NOT all host-routes are installed (some missing) — text must say so.
'unreachable': 'Not fully applied — one or more host-routes are missing; re-apply or check the interface gateway.',
'stale': 'Stale Ethernet routes remain from a previous selection — re-apply to clean up.',
};
// Rollup code → severity tone for status COLOR only. Honesty: the text above is
// unchanged; tone just lets an operator tell a healthy uplink from a failing one
// at a glance (a failure must not look identical to a success).
const _ROLLUP_TONE = {
unset: 'na', pending: 'warn', egress: 'ok', unreachable: 'error', stale: 'warn',
};
let _data = null; // last GET response (uplink_config / options / status)
let _selected = null; // pending UI selection (string iface)
let _saving = false; // concurrent click guard
let _statusTimer = null; // periodic refresh timer
let _epoch = 0; // bumped on destroy() — orphans in-flight _refresh resolutions
let _lastRollup = null; // last rendered rollup code — only re-announce (aria-live) when it changes
let _lastUpdated = null; // Date of last successful status fetch (for "Last updated")
let _staleError = null; // last refresh-failure message while keeping the last-good table
function _currentIface() {
return (_data && _data.uplink_config && _data.uplink_config.telemetry_iface) || 'wlan0';
}
function _optionsHtml() {
const opts = (_data && _data.options) || [];
const sel = _selected || _currentIface();
return opts.map(o => {
const label = _IFACE_LABELS[o.iface] || o.iface;
const disabled = !o.gateway_present;
const reason = o.disabled_reason || (disabled ? 'no_gateway' : null);
// Cleanest hinting: a disabled option states its reason; the default-route option
// (wlan0) is marked "system default"; other healthy options show no hint (no clutter,
// and no inaccurate "gateway present" — eth0/eth1 just route per-server when selected).
let hint = '';
if (disabled) {
hint = `<span class="uplink-opt__hint uplink-opt__hint--warn">${escapeHtml(_REASON_LABELS[reason] || 'unavailable')}</span>`;
} else if (o.iface === _DEFAULT_IFACE) {
hint = '<span class="uplink-opt__hint">system default</span>';
}
const reasonNote = disabled && reason
? `<p class="uplink-opt__note">${escapeHtml(_DISABLED_REASONS[reason] || reason)}</p>`
: '';
const checked = (o.iface === sel && !disabled) ? 'checked' : '';
return `
<label class="uplink-opt ${disabled ? 'uplink-opt--disabled' : ''}">
<input type="radio" name="telemetry_iface" value="${escapeHtml(o.iface)}" ${checked} ${disabled ? 'disabled' : ''}>
<span class="uplink-opt__label">${escapeHtml(label)}</span>
${hint}
${reasonNote}
</label>`;
}).join('');
}
function _advisoriesHtml() {
const sel = _selected || _currentIface();
const opt = ((_data && _data.options) || []).find(o => o.iface === sel);
const adv = (opt && opt.advisories) || [];
if (!adv.length) return '';
return adv.map(a =>
`<p class="uplink-status__msg uplink-status__msg--warning uplink-advisory">${escapeHtml(a)}</p>`
).join('');
}
function _renderAdvisories() {
const el = document.getElementById('uplink-advisories');
if (el) el.innerHTML = _advisoriesHtml();
}
function _rollupCode() {
return (_data && _data.status && _data.status.rollup) || null;
}
function _rollupText(code) { return _ROLLUP_TEXT[code] || code || ''; }
function _rollupTone(code) { return _ROLLUP_TONE[code] || 'na'; }
function _fmtTime(d) {
try { return d.toLocaleTimeString(); } catch (_) { return ''; }
}
// The dynamic status body: the per-target table (or a distinct loading/empty/error
// message). NOT a live region — the rollup line below carries the announcement.
function _statusBodyHtml() {
if (!_data || !_data.status) {
return _staleError
? `<p class="uplink-status__msg uplink-status__msg--error">Status unavailable — ${escapeHtml(_staleError)}</p>`
: `<p class="uplink-status__msg uplink-status__msg--loading">Loading status…</p>`;
}
const s = _data.status;
const rows = (s.per_target || []).map(p => {
// Honesty: "Route ready" describes the host-route being in place — NOT that traffic flows.
// Confirmation of actual traffic is the "Traffic" column (ss-observed egress interface).
// In default-route mode (wlan0) NO per-server /32 is expected, so "No route yet" would
// mis-read as a fault — show "System default" instead.
// §5.1 fate — first match wins; on-link rows take precedence over mode/route rows because
// an on-link target's egress is mode-independent (it always uses its own LAN).
let routeText;
if (p.on_link_iface && p.on_link_iface === s.iface) {
routeText = `On ${p.on_link_iface} — local (direct)`;
} else if (p.on_link_iface) {
routeText = `On ${p.on_link_iface} — local LAN (not steered)`;
} else if (s.iface === _DEFAULT_IFACE) {
routeText = 'System default';
} else if (p.route_installed) {
routeText = 'Route ready'; // route PRESENT, not a claim traffic flows (see Traffic column)
} else {
routeText = 'No route yet';
}
const traffic = p.egress_iface || 'Not yet observed';
// Server cell: role label (TIOT / Update / DGPS) alongside the IP, when known.
const server = p.label
? `<span class="uplink-target__role">${escapeHtml(p.label)}</span> ${escapeHtml(p.ip)}`
: escapeHtml(p.ip);
return `
<tr>
<td>${server}</td>
<td>${escapeHtml(routeText)}</td>
<td>${escapeHtml(traffic)}</td>
</tr>`;
}).join('');
const empty = (s.per_target || []).length === 0
? `<tr><td colspan="3" class="uplink-status__msg uplink-status__msg--empty">No telemetry targets configured (set protocol/update/RTCM server IPs first).</td></tr>`
: '';
const errBanner = _staleError
? `<p class="uplink-status__msg uplink-status__msg--error">Status refresh failed — showing last known result. ${escapeHtml(_staleError)}</p>`
: '';
const stamp = _lastUpdated
? `<p class="uplink-status__stamp">Last updated ${escapeHtml(_fmtTime(_lastUpdated))}</p>`
: '';
return `
${errBanner}
<div class="data-table-wrapper">
<table class="uplink-status data-table">
<thead><tr><th>Server</th><th>Route</th><th>Traffic</th></tr></thead>
<tbody>${rows}${empty}</tbody>
</table>
</div>
${stamp}`;
}
function _render(container) {
const code = _rollupCode();
_lastRollup = code;
container.innerHTML = `
<div class="page-header">
<h1 class="page-header__title">${icon('upload', { size: 28 })} Telemetry Uplink</h1>
<p class="page-header__desc">Route the TIOT (telemetry), Update (firmware) and DGPS (RTCM) server traffic over the selected interface using per-server host-routes. Management access (this configurator, SSH) is unaffected.</p>
</div>
<div class="card uplink-card">
<fieldset class="uplink-fieldset">
<legend class="uplink-card__title">Uplink interface</legend>
<div class="uplink-options" id="uplink-options">${_optionsHtml()}</div>
</fieldset>
<div id="uplink-advisories" aria-live="polite">${_advisoriesHtml()}</div>
<div class="dash-actions">
<button class="btn btn--primary" id="uplink-save-apply" type="button" ${_saving ? 'disabled' : ''}>
${icon('check-circle-2', { size: 14 })} Save &amp; Apply
</button>
</div>
</div>
<div class="card uplink-card">
<h2 class="uplink-card__title">Status</h2>
<div id="uplink-status">
<div id="uplink-status-body">${_statusBodyHtml()}</div>
<p id="uplink-rollup" class="uplink-status__rollup" data-rollup="${_rollupTone(code)}" aria-live="polite">${escapeHtml(_rollupText(code))}</p>
</div>
</div>`;
}
function _bindRadios(scope) {
scope.querySelectorAll('input[name="telemetry_iface"]').forEach(r => {
r.addEventListener('change', () => {
if (r.checked && !r.disabled) { _selected = r.value; _renderAdvisories(); }
});
});
}
function _bind(container) {
_bindRadios(container);
container.querySelector('#uplink-save-apply')?.addEventListener('click', () => _saveAndApply());
}
// Update ONLY the dynamic subtrees, preserving radio keyboard focus and not
// spamming screen readers (rollup re-announced only when its code changes).
function _applyRefreshDom() {
const optEl = document.getElementById('uplink-options');
// Don't rebuild the radio group while the operator is focused inside it (would steal focus mid-selection).
if (optEl && !optEl.contains(document.activeElement)) {
optEl.innerHTML = _optionsHtml();
_bindRadios(optEl);
}
const bodyEl = document.getElementById('uplink-status-body');
if (bodyEl) bodyEl.innerHTML = _statusBodyHtml();
const code = _rollupCode();
if (code !== _lastRollup) {
const rEl = document.getElementById('uplink-rollup');
if (rEl) {
rEl.setAttribute('data-rollup', _rollupTone(code));
rEl.textContent = _rollupText(code);
}
_lastRollup = code;
}
_renderAdvisories();
}
async function _refresh() {
const myEpoch = _epoch;
let data;
try {
data = await getUplink();
} catch (e) {
if (myEpoch !== _epoch) return; // torn down during await — don't touch the DOM
// Fail-soft: keep last-good _data and the last-known table visible; surface a
// distinct error notice over it instead of blanking the panel (matches peer poll convention).
_staleError = e.message || 'load failed';
const bodyEl = document.getElementById('uplink-status-body');
if (bodyEl) bodyEl.innerHTML = _statusBodyHtml();
return;
}
if (myEpoch !== _epoch) return; // page torn down / re-mounted while the GET was in flight
_data = data;
_staleError = null;
_lastUpdated = new Date();
// Reconcile UI selection with backend: if the user hasn't picked anything yet,
// mirror the backend's current iface; if they had, keep their choice when valid.
const valid = new Set((_data.options || []).filter(o => o.gateway_present).map(o => o.iface));
if (!_selected || !valid.has(_selected)) _selected = _currentIface();
if (document.getElementById('uplink-status-body')) {
_applyRefreshDom(); // subtree update — preserves focus + avoids SR churn
} else {
const c = document.getElementById('page-container'); // first-mount race fallback
if (c) { _render(c); _bind(c); }
}
}
async function _saveAndApply() {
if (_saving) return;
const iface = _selected || _currentIface();
if (!_data) {
showToast('Uplink data not loaded yet.', 'error');
return;
}
const opt = (_data.options || []).find(o => o.iface === iface);
if (!opt || !opt.gateway_present) {
showToast(`Cannot select ${iface}: ${opt?.disabled_reason || 'no gateway'}.`, 'error');
return;
}
if (iface === _currentIface()) {
showToast(`Telemetry is already routed over ${iface}.`, 'info');
return;
}
const adv = (opt && opt.advisories) || [];
const advText = adv.length ? `Effects of this selection: ${adv.join(' ')} ` : '';
const ok = await confirmModal({
title: 'Switch telemetry uplink?',
message: `${advText}Telemetry will switch to ${iface}. To make the switch take effect now, ${_RESTART_ACTION} — until then dpworldapp keeps its current connections on the old path. If the servers are not reachable via ${iface}, transmission stops until you switch back. Management access (this configurator, SSH) is not affected.`,
confirmLabel: 'Switch & Apply',
cancelLabel: 'Cancel',
danger: false, // route-only, non-disruptive to management access; the caveat lives in the message body
});
if (!ok) return;
_saving = true;
const saveApplyBtn = document.getElementById('uplink-save-apply');
const origBtnHtml = saveApplyBtn ? saveApplyBtn.innerHTML : '';
if (saveApplyBtn) { saveApplyBtn.disabled = true; saveApplyBtn.textContent = 'Saving…'; }
try {
await postUplink({ telemetry_iface: iface });
clearDirty('uplink');
// Reinforce the action: routes are applied, but dpworldapp must reconnect to switch.
showToast(`Uplink switch applied — ${_RESTART_ACTION} to take effect now.`, 'success');
} catch (e) {
showToast('Failed — ' + (e.message || 'apply error'), 'error');
} finally {
_saving = false;
if (saveApplyBtn) { saveApplyBtn.disabled = false; saveApplyBtn.innerHTML = origBtnHtml; }
}
await _refresh(); // re-render AFTER _saving cleared so the button re-enables immediately
}
const uplinkPage = {
render(container) { _render(container); _bind(container); },
mount(container) {
_refresh();
if (!_statusTimer) _statusTimer = setInterval(_refresh, 15000);
},
destroy() {
_epoch++; // orphan any in-flight _refresh so it won't write to a torn-down/other page
if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; }
_data = null;
_selected = null;
_saving = false;
_lastRollup = null;
_lastUpdated = null;
_staleError = null;
},
validate() { return []; }, // route-only; selection is constrained by radios
async saveSelfContained() {
// Global Save All entry point: route-only apply, no confirm modal (Save All
// already gathered confirmation upstream for the whole batch).
const iface = _selected || _currentIface();
if (iface === _currentIface()) return;
await postUplink({ telemetry_iface: iface });
clearDirty('uplink');
// Refresh only when the uplink page is actually mounted (no-op during a batch
// Save All triggered from another page — avoids rendering into a foreign container).
if (document.getElementById('uplink-status')) await _refresh();
},
};
export default uplinkPage;

40
src/static/js/pages/wifi-ap.js

@ -9,15 +9,40 @@ import { clearDirty } from '../page-dirty.js';
const _DEFAULTS = { ap_enabled: false, ap_ssid: '', ap_passphrase: '', ap_band: 'auto', ap_channel: 0, ap_hidden: false };
let _cfg = { ..._DEFAULTS }; // last loaded/saved config
let _status = {}; // live status (ap_enabled, ap0_up, hostapd_running, clients, country_pending)
let _status = {}; // live status (ap_enabled, ap0_up, hostapd_running, clients, client_details)
let _countryPending = false;
let _statusTimer = null;
// Fix #3b: track whether the backend has a stored PSK (from status.has_passphrase).
// null = not yet loaded (status not fetched); true/false = known.
// Used in _validate() to reject blank PSK when AP is being enabled on a fresh device.
let _hasPassphrase = null;
// v1.11.9 (review LOW concurrency): _save is async and bound to three buttons
// (ap-save / ap-apply / ap-save-apply) — guard against a double-apply re-entry.
let _saving = false;
function _byteLen(s) { return new TextEncoder().encode(s || '').length; }
function _badge(cls, text) { return `<span class="net-badge net-badge--${cls}">${escapeHtml(text)}</span>`; }
function _fmtDbm(v) { return Number.isFinite(v) ? `${v} dBm` : 'Unknown'; }
function _fmtIdle(v) { return Number.isFinite(v) ? `${v} ms` : 'Unknown'; }
function _clientDetailsHtml() {
const clients = Array.isArray(_status.client_details) ? _status.client_details : [];
if (!clients.length) return '';
const rows = clients.map(c => `
<tr>
<td class="ap-client-table__mono">${escapeHtml(c.mac || 'unknown')}</td>
<td class="ap-client-table__mono">${escapeHtml(c.ip || 'IP pending')}</td>
<td>${escapeHtml(_fmtDbm(c.signal_dbm))}</td>
<td>${escapeHtml(_fmtIdle(c.inactive_ms))}</td>
</tr>`).join('');
return `
<div class="data-table-wrapper ap-client-table">
<table class="data-table">
<thead><tr><th>MAC</th><th>IP</th><th>Signal</th><th>Idle</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
async function _api(path, opts) {
const res = await fetch(path, opts);
@ -55,11 +80,17 @@ function _validate() {
// v1.11.9 (review HIGH security UX): a BLANK PSK field means "keep the existing
// stored password" (_collect omits ap_passphrase entirely). Only validate the
// 8-63 byte bound when the user actually typed a NEW passphrase.
// Fix #3b: on a fresh device (_hasPassphrase=false) a blank PSK has nothing to
// "keep" — the server-side RMW would store an empty PSK and fail apply-time
// validation. Reject proactively so the error is shown client-side immediately.
if ('ap_passphrase' in d) {
const p = _byteLen(d.ap_passphrase);
if (p < 8 || p > 63) {
showFieldError(document.getElementById('ap_passphrase'), 'Password must be 8-63 bytes.'); errs.push('psk');
}
} else if (_hasPassphrase === false) {
// Blank PSK + no stored PSK: require the user to set one now.
showFieldError(document.getElementById('ap_passphrase'), 'Wi-Fi AP requires a passphrase (8–63 characters).'); errs.push('psk');
}
}
setPageError('wifi-ap', errs.length > 0);
@ -77,7 +108,7 @@ function _statusHtml() {
const apBadge = running ? _badge('ok', 'Running') : _badge('na', 'Off');
const rows = [
`<div class="dash-row"><span class="dash-row__lbl">Access point</span><span class="dash-row__val">${apBadge}</span></div>`,
`<div class="dash-row"><span class="dash-row__lbl">${icon('smartphone', { size: 14 })} Connected clients</span>
`<div class="dash-row"><span class="dash-row__lbl">${icon('smartphone', { size: 14 })} Associated clients</span>
<span class="dash-row__val">${escapeHtml(String(_status.clients ?? 0))}</span></div>`,
];
if (adv) {
@ -90,7 +121,7 @@ function _statusHtml() {
}
const refresh = adv
? `<button class="btn btn--ghost btn--sm" id="ap-refresh" type="button">${icon('refresh-cw', { size: 14 })} Refresh</button>` : '';
return `<div class="dash-card"><h3 class="dash-card__title">${icon('activity', { size: 16 })} Status ${refresh}</h3>${rows.join('')}</div>`;
return `<div class="dash-card"><h3 class="dash-card__title">${icon('activity', { size: 16 })} Status ${refresh}</h3>${rows.join('')}${_clientDetailsHtml()}</div>`;
}
function _render(container) {
@ -176,6 +207,8 @@ async function _refreshStatus() {
const s = await _api('api/network/ap/status');
_status = s || {};
_countryPending = !!s.country_pending;
// Fix #3b: capture has_passphrase (boolean from backend, null = not yet known).
if (typeof s.has_passphrase === 'boolean') _hasPassphrase = s.has_passphrase;
if (s.config) _cfg = { ..._DEFAULTS, ...s.config };
const el = document.getElementById('ap-status');
if (el) {
@ -202,6 +235,7 @@ const wifiApPage = {
_status = {};
_countryPending = false;
_saving = false;
_hasPassphrase = null; // Fix #3b: reset so next mount re-fetches from backend
},
validate() {
if (!document.getElementById('ap_enabled')) return [];

8
src/static/js/state.js

@ -16,6 +16,9 @@ export const state = {
protocol: null,
currentPage: 'ssid',
isDirty: false,
/** B6: set true when loadAllData catches an error; cleared on successful load.
* handleSaveAll refuses to run when true to prevent empty-defaults from wiping real DB. */
configLoadFailed: false,
/**
* v1.5.0 P1: per-page dirty matrix.
* Phase 1: 9 기존 page id + firmware placeholder.
@ -40,6 +43,7 @@ export const state = {
log: false,
firmware: false,
'net-apply': false, // v1.6.0
uplink: false, // Telemetry Uplink — self-contained Save&Apply
},
};
@ -453,7 +457,7 @@ function _arraySummary(oldArr, newArr) {
if (o.length !== n.length) {
return { old: `${o.length} entries`, new: `${n.length} entries` };
}
return { old: '(내용 변경)', new: '(내용 변경)' };
return { old: '(modified)', new: '(modified)' };
}
/**
@ -503,7 +507,7 @@ function _diffTree(baseline, current, domain, attributeTop, attributeNested) {
// Deeper than one level — compare by JSON equality, summarize.
if (JSON.stringify(sbv) !== JSON.stringify(scv)) {
const page = attributeNested(key, sk);
out.push({ page, domain, field: `${key}.${sk}`, old: '(내용 변경)', new: '(내용 변경)' });
out.push({ page, domain, field: `${key}.${sk}`, old: '(modified)', new: '(modified)' });
}
continue;
}

19
src/support_bundle.py

@ -30,23 +30,6 @@ def mask_passwords(device_config):
return cfg
def _newest_log_path(log_dir):
if not os.path.isdir(log_dir):
return None
newest, newest_mtime = None, 0
try:
for name in os.listdir(log_dir):
if not name.endswith(".log"):
continue
p = os.path.join(log_dir, name)
if os.path.isfile(p):
m = os.stat(p).st_mtime
if m > newest_mtime:
newest, newest_mtime = p, m
except OSError:
return None
return newest
def build_support_bundle(db):
"""Build the diagnostics zip. Returns the zip file content as bytes."""
@ -68,7 +51,7 @@ def build_support_bundle(db):
json.dumps(protocol, indent=2, ensure_ascii=False))
# newest dpworldapp log (capped)
log_path = _newest_log_path(system_status.LOG_DIR)
log_path = system_status._find_newest_dpworld_log(system_status.LOG_DIR)
if log_path:
try:
with open(log_path, "rb") as f:

11
src/system_status.py

@ -35,6 +35,7 @@ LOG_IDLE_SECONDS = 300 # log mtime younger than this → idle, else stale
# scanners use a line iterator (Python buffered I/O) and need no
# chunk size or cap — memory is bounded by line length.
DPWORLD_LOG_TAIL_BYTES = 4096
_FUTURE_LOG_SKEW_S = 60 # files dated more than this far in the future are skipped
# ─── v1.2.0: compiled regex patterns for log scanning ──────────
_FWVE_RE = re.compile(r'"FWVE"\s*:\s*"([^"]+)"')
@ -235,6 +236,8 @@ def _find_newest_dpworld_log(log_dir):
if not os.path.isfile(p):
continue
m = os.stat(p).st_mtime
if m > time.time() + _FUTURE_LOG_SKEW_S:
continue
if m > newest_mtime:
newest_mtime, newest_path = m, p
except OSError:
@ -660,8 +663,6 @@ def get_dpworldapp_status(db, log_dir=LOG_DIR):
- phase markers scanned from the relevant log file (cached + frozen
once steady_state is reached)
- runtime config from board_config (protocol_config + device_config)
Shape follows the dpworldapp runtime status contract.
"""
global _dpworld_status_cache
@ -776,8 +777,10 @@ def get_log_freshness(log_dir):
last_line = stripped
except OSError:
pass
now = time.time()
age_seconds = None if mtime > now + _FUTURE_LOG_SKEW_S else max(0.0, now - mtime)
return {
"age_seconds": max(0.0, time.time() - mtime),
"age_seconds": age_seconds,
"last_line": last_line,
"dpworld_version": _get_dpworld_version_cached(log_dir),
}
@ -864,7 +867,7 @@ def get_process_status(unit=DPWORLDAPP_UNIT):
proc_pid = _dpworldapp_pid_from_proc()
if proc_pid is not None:
return {"running": True, "unit_managed": False,
"pid": proc_pid, "uptime_seconds": uptime_seconds, "unit": unit,
"pid": proc_pid, "uptime_seconds": None, "unit": unit,
"detail": "running, not under systemd supervision"}
return {"running": False, "unit_managed": False,

Loading…
Cancel
Save